Initial commit: Next Step health management app

A calm, reliable app to help manage appointments, medications, and notes
for chemo patients and their families.

Features:
- Today dashboard with next appointment and medications due
- Medication tracking with multiple schedule types (fixed times, interval, weekdays, PRN)
- One-tap dose logging with 5-minute undo window
- Questions for doctor tracking
- Family sharing with workspace model and invite links
- Offline-first with IndexedDB and sync
- Docker Compose deployment with Tailscale Funnel support

Tech stack:
- Next.js 14 (App Router) + TypeScript + Tailwind CSS
- PostgreSQL + Prisma
- Argon2 password hashing + session cookies
- Dexie.js for IndexedDB

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Gemini Agent
2026-01-18 23:16:45 +00:00
commit a32c609830
76 changed files with 9406 additions and 0 deletions

52
.dockerignore Normal file
View File

@@ -0,0 +1,52 @@
# Dependencies
node_modules
.pnp
.pnp.js
# Testing
coverage
# Next.js
.next
out
# Production
build
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Vercel
.vercel
# IDE
.idea
.vscode
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Git
.git
.gitignore
# Docker
Dockerfile
docker-compose*.yml
.dockerignore
# Misc
README.md
*.md

20
.env.example Normal file
View File

@@ -0,0 +1,20 @@
# Database
DATABASE_URL="postgresql://nextstep:nextstep@localhost:5432/nextstep?schema=public"
# App Configuration
NEXTAUTH_SECRET="generate-a-random-secret-here-at-least-32-chars"
NEXT_PUBLIC_APP_URL="http://localhost:3000"
# Timezone (important for medication scheduling)
TZ="Australia/Perth"
# Rate Limiting
RATE_LIMIT_MAX_REQUESTS=100
RATE_LIMIT_WINDOW_MS=60000
# Login Security
LOGIN_MAX_ATTEMPTS=5
LOGIN_LOCKOUT_MINUTES=15
# Session
SESSION_MAX_AGE_DAYS=30

7
.eslintrc.json Normal file
View File

@@ -0,0 +1,7 @@
{
"extends": ["next/core-web-vitals"],
"rules": {
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
"react/no-unescaped-entities": "off"
}
}

40
.gitignore vendored Normal file
View File

@@ -0,0 +1,40 @@
# Dependencies
/node_modules
/.pnp
.pnp.js
# Testing
/coverage
# Next.js
/.next/
/out/
# Production
/build
# Misc
.DS_Store
*.pem
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Local env files
.env
.env*.local
# Vercel
.vercel
# TypeScript
*.tsbuildinfo
next-env.d.ts
# IDE
.idea/
.vscode/
*.swp
*.swo

60
Dockerfile Normal file
View File

@@ -0,0 +1,60 @@
# Stage 1: Dependencies
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat python3 make g++
WORKDIR /app
COPY package.json package-lock.json* ./
COPY prisma ./prisma/
RUN npm ci
# Stage 2: Builder
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Generate Prisma client
RUN npx prisma generate
# Build the application
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
RUN npm run build
# Stage 3: Runner
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV TZ=Australia/Perth
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Install tzdata for timezone support
RUN apk add --no-cache tzdata
COPY --from=builder /app/public ./public
COPY --from=builder /app/prisma ./prisma
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Run database migrations before starting
CMD ["sh", "-c", "npx prisma migrate deploy && node server.js"]

318
README.md Normal file
View File

@@ -0,0 +1,318 @@
# Next Step
A calm, reliable health management app for families supporting a loved one through treatment. Built to be "mum-proof" — simple, clear, and accessible.
![Next Step](https://via.placeholder.com/800x400/3a9563/ffffff?text=Next+Step)
## Features
### Today Dashboard
- **Next appointment** with location and map link
- **Medications due** with one-tap "Taken" button
- **Quick note** for jotting down thoughts
- **Call clinic** button for easy access
### Appointments
- Simple timeline view with date groupings
- Add title, date/time, location, map link, and notes
- Soft delete with recovery
### Medications
- **Multiple schedule types:**
- Fixed times daily (e.g., 8am, 8pm)
- Every X hours (e.g., every 8 hours)
- Specific weekdays (e.g., Mon/Wed/Fri at 9am)
- PRN/As needed with cooldown period
- One-tap dose logging with 5-minute undo window
- "What did I take?" history view (last 7 days)
- Overdue indicators with grace period
### Notes
- **Questions for doctor** — track what to ask, mark as asked
- **General notes** — timestamped thoughts
- Copy questions for appointments
### Family Sharing
- Workspace model (e.g., "Grace's Plan")
- Invite family via link
- Roles: Owner, Editor, Viewer
- Audit log of all changes
### Offline-First
- Works without internet connection
- IndexedDB local cache
- Automatic sync when online
- Conflict detection with "updated on another device" banner
## Tech Stack
- **Frontend:** Next.js 14 (App Router), TypeScript, Tailwind CSS
- **Database:** PostgreSQL with Prisma ORM
- **Auth:** Session cookies with argon2 password hashing
- **Offline:** IndexedDB via Dexie.js
- **Deployment:** Docker Compose
## Quick Start
### Prerequisites
- Docker and Docker Compose
- Tailscale (for external access)
### 1. Clone and Configure
```bash
cd /path/to/nextstep
# Copy environment template
cp .env.example .env
# Edit .env and set:
# - NEXTAUTH_SECRET (generate with: openssl rand -base64 32)
# - DB_PASSWORD (choose a secure password)
# - NEXT_PUBLIC_APP_URL (your Tailscale Funnel URL)
```
### 2. Start the Application
```bash
docker compose up -d
```
The app will:
1. Build the Next.js application
2. Start PostgreSQL
3. Run database migrations
4. Start the app on `127.0.0.1:3000`
### 3. Set Up Tailscale Funnel
Tailscale Funnel exposes your local app to the internet with automatic HTTPS.
```bash
# Enable Funnel (one-time setup)
tailscale funnel --https=443 http://127.0.0.1:3000 --bg
# Check status
tailscale funnel status
# Your app is now accessible at:
# https://[your-machine-name].[your-tailnet].ts.net
```
### 4. Update Your Environment
Edit `.env` and set `NEXT_PUBLIC_APP_URL` to your Funnel URL:
```
NEXT_PUBLIC_APP_URL=https://your-machine.your-tailnet.ts.net
```
Then restart the app:
```bash
docker compose down
docker compose up -d
```
### 5. Create Your Account
1. Open your Funnel URL in a browser
2. Click "Create Account"
3. Accept the disclaimer
4. Create your workspace (e.g., "Grace's Plan")
5. Add your clinic phone number
## Development
### Local Development
```bash
# Install dependencies
npm install
# Set up local PostgreSQL (or use Docker)
docker run -d \
--name nextstep-postgres \
-e POSTGRES_USER=nextstep \
-e POSTGRES_PASSWORD=nextstep \
-e POSTGRES_DB=nextstep \
-p 5432:5432 \
postgres:16-alpine
# Set up environment
cp .env.example .env
# Edit .env with DATABASE_URL=postgresql://nextstep:nextstep@localhost:5432/nextstep
# Generate Prisma client
npm run db:generate
# Run migrations
npm run db:migrate
# Start dev server
npm run dev
```
### Running Tests
```bash
npm test
```
Tests cover:
- Medication scheduling logic
- PRN cooldown calculations
- Fixed times, interval, and weekday schedules
## Environment Variables
| Variable | Description | Required |
|----------|-------------|----------|
| `DATABASE_URL` | PostgreSQL connection string | Yes |
| `NEXTAUTH_SECRET` | Session encryption secret (min 32 chars) | Yes |
| `NEXT_PUBLIC_APP_URL` | Public URL of the app | Yes |
| `DB_PASSWORD` | PostgreSQL password (for Docker) | Yes |
| `TZ` | Timezone (default: Australia/Perth) | No |
| `RATE_LIMIT_MAX_REQUESTS` | Max requests per minute (default: 100) | No |
| `LOGIN_MAX_ATTEMPTS` | Failed logins before lockout (default: 5) | No |
| `LOGIN_LOCKOUT_MINUTES` | Lockout duration (default: 15) | No |
| `SESSION_MAX_AGE_DAYS` | Session lifetime (default: 30) | No |
## API Endpoints
### Authentication
- `POST /api/auth/register` — Create account
- `POST /api/auth/login` — Sign in
- `POST /api/auth/logout` — Sign out
- `GET /api/auth/me` — Get current user
### Workspaces
- `GET /api/workspaces` — List user's workspaces
- `POST /api/workspaces` — Create workspace
- `GET /api/workspaces/[id]` — Get workspace details
- `PATCH /api/workspaces/[id]` — Update workspace settings
- `POST /api/workspaces/[id]/invite` — Create invite link
- `GET /api/invite/[token]` — Get invite details
- `POST /api/invite/[token]` — Accept invite
### Sync
- `GET /api/sync?workspaceId=...&since=...` — Pull changes
- `POST /api/sync` — Push offline operations
### Health
- `GET /api/health` — Health check
## Security
- **Password hashing:** Argon2id with secure parameters
- **Session cookies:** HTTPOnly, Secure, SameSite=Lax
- **Rate limiting:** Per-IP request limits
- **Login protection:** Lockout after failed attempts
- **Input validation:** Zod schemas on all endpoints
- **HTTPS:** Enforced via Tailscale Funnel
## Tailscale Funnel Commands
```bash
# Start Funnel (background mode)
tailscale funnel --https=443 http://127.0.0.1:3000 --bg
# Check Funnel status
tailscale funnel status
# Stop Funnel
tailscale funnel off
# View Funnel logs
tailscale funnel status --json
```
## Backup & Restore
### Backup Database
```bash
docker exec nextstep-db pg_dump -U nextstep nextstep > backup.sql
```
### Restore Database
```bash
cat backup.sql | docker exec -i nextstep-db psql -U nextstep nextstep
```
### Export User Data
Users can export their data as JSON from Settings > Export Data.
## Troubleshooting
### App won't start
```bash
# Check logs
docker compose logs app
# Common issues:
# - Database not ready: wait a few seconds, it will retry
# - Missing env vars: check .env file
```
### Database connection failed
```bash
# Check database is running
docker compose ps
# Check database logs
docker compose logs db
# Verify connection string in .env
```
### Tailscale Funnel not working
```bash
# Ensure Funnel is enabled for your tailnet
# (requires admin access to Tailscale admin console)
# Check if Funnel is running
tailscale funnel status
# Restart Funnel
tailscale funnel off
tailscale funnel --https=443 http://127.0.0.1:3000 --bg
```
## Architecture
```
┌─────────────────────────────────────────────────────────┐
│ Tailscale Funnel │
│ (HTTPS termination) │
└────────────────────────┬────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Next.js App │
│ (127.0.0.1:3000) │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │
│ │ App │ │ API │ │ Auth │ │
│ │ Router │ │ Routes │ │ (Sessions) │ │
│ └─────────────┘ └─────────────┘ └─────────────────┘ │
└────────────────────────┬────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ PostgreSQL │
│ (Internal only) │
└─────────────────────────────────────────────────────────┘
```
## License
MIT License. See LICENSE file for details.
## Disclaimer
**Next Step is a tracking tool only.** It does not provide medical advice. Always consult your healthcare team for medical decisions. For emergencies, call 000 (Australia) or your local emergency services.

62
docker-compose.yml Normal file
View File

@@ -0,0 +1,62 @@
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: nextstep-app
restart: unless-stopped
ports:
- "127.0.0.1:3000:3000" # Bind to localhost only for Tailscale Funnel
environment:
- DATABASE_URL=postgresql://nextstep:${DB_PASSWORD:-nextstep}@db:5432/nextstep?schema=public
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000}
- TZ=Australia/Perth
- NODE_ENV=production
depends_on:
db:
condition: service_healthy
networks:
- nextstep-network
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
db:
image: postgres:16-alpine
container_name: nextstep-db
restart: unless-stopped
environment:
- POSTGRES_USER=nextstep
- POSTGRES_PASSWORD=${DB_PASSWORD:-nextstep}
- POSTGRES_DB=nextstep
- TZ=Australia/Perth
- PGTZ=Australia/Perth
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- nextstep-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U nextstep -d nextstep"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
# Do not expose PostgreSQL to the host - only accessible within the network
# If you need direct access, uncomment below:
# ports:
# - "127.0.0.1:5432:5432"
volumes:
postgres_data:
name: nextstep-postgres-data
networks:
nextstep-network:
name: nextstep-network
driver: bridge

12
next.config.js Normal file
View File

@@ -0,0 +1,12 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
serverActions: {
bodySizeLimit: '2mb',
},
},
// Output standalone for Docker
output: 'standalone',
}
module.exports = nextConfig

54
package.json Normal file
View File

@@ -0,0 +1,54 @@
{
"name": "next-step",
"version": "1.0.0",
"description": "Family health management app for chemo patients",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"test": "vitest run",
"test:watch": "vitest",
"db:generate": "prisma generate",
"db:push": "prisma db push",
"db:migrate": "prisma migrate dev",
"db:migrate:deploy": "prisma migrate deploy",
"db:seed": "tsx prisma/seed.ts",
"postinstall": "prisma generate"
},
"dependencies": {
"@prisma/client": "^5.22.0",
"argon2": "^0.41.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"dexie": "^4.0.10",
"dexie-react-hooks": "^1.1.7",
"lucide-react": "^0.468.0",
"nanoid": "^5.0.9",
"next": "^14.2.21",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hot-toast": "^2.4.1",
"zod": "^3.24.1"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.9",
"@types/node": "^22.10.5",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"autoprefixer": "^10.4.20",
"eslint": "^8.57.1",
"eslint-config-next": "^14.2.21",
"postcss": "^8.4.49",
"prisma": "^5.22.0",
"tailwindcss": "^3.4.17",
"tsx": "^4.19.2",
"typescript": "^5.7.3",
"vitest": "^2.1.8"
},
"engines": {
"node": ">=18.0.0"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

307
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,307 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ============================================
// USER & AUTHENTICATION
// ============================================
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
sessions Session[]
workspaceMembers WorkspaceMember[]
createdAppointments Appointment[] @relation("AppointmentCreatedBy")
updatedAppointments Appointment[] @relation("AppointmentUpdatedBy")
createdMedications Medication[] @relation("MedicationCreatedBy")
updatedMedications Medication[] @relation("MedicationUpdatedBy")
createdNotes Note[] @relation("NoteCreatedBy")
updatedNotes Note[] @relation("NoteUpdatedBy")
loggedDoses DoseLog[] @relation("DoseLoggedBy")
undoneDoses DoseLog[] @relation("DoseUndoneBy")
auditLogs AuditLog[]
@@index([email])
}
model Session {
id String @id @default(cuid())
userId String
token String @unique
expiresAt DateTime
createdAt DateTime @default(now())
userAgent String?
ipAddress String?
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([token])
@@index([userId])
@@index([expiresAt])
}
model LoginAttempt {
id String @id @default(cuid())
email String
ipAddress String?
success Boolean
createdAt DateTime @default(now())
@@index([email, createdAt])
@@index([ipAddress, createdAt])
}
// ============================================
// WORKSPACE & SHARING
// ============================================
model Workspace {
id String @id @default(cuid())
name String // e.g., "Grace's Plan"
clinicPhone String?
emergencyPhone String?
quietHoursStart String? // HH:mm format
quietHoursEnd String? // HH:mm format
largeTextMode Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
members WorkspaceMember[]
inviteTokens InviteToken[]
appointments Appointment[]
medications Medication[]
notes Note[]
doseLogs DoseLog[]
auditLogs AuditLog[]
syncCursors SyncCursor[]
@@index([name])
}
enum WorkspaceRole {
OWNER
EDITOR
VIEWER
}
model WorkspaceMember {
id String @id @default(cuid())
workspaceId String
userId String
role WorkspaceRole @default(VIEWER)
createdAt DateTime @default(now())
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([workspaceId, userId])
@@index([userId])
}
model InviteToken {
id String @id @default(cuid())
workspaceId String
token String @unique
role WorkspaceRole @default(VIEWER)
expiresAt DateTime
usedAt DateTime?
usedById String?
createdAt DateTime @default(now())
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@index([token])
@@index([workspaceId])
}
// ============================================
// APPOINTMENTS
// ============================================
model Appointment {
id String @id @default(cuid())
workspaceId String
title String
datetime DateTime
location String?
mapUrl String?
notes String?
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById String
updatedById String
// Sync tracking
version Int @default(1)
syncedAt DateTime @default(now())
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
createdBy User @relation("AppointmentCreatedBy", fields: [createdById], references: [id])
updatedBy User @relation("AppointmentUpdatedBy", fields: [updatedById], references: [id])
@@index([workspaceId, datetime])
@@index([workspaceId, deletedAt])
@@index([syncedAt])
}
// ============================================
// MEDICATIONS
// ============================================
enum ScheduleType {
FIXED_TIMES // e.g., 08:00, 20:00 daily
INTERVAL // every X hours
WEEKDAYS // specific weekdays at a time
PRN // as needed with min hours between
}
model Medication {
id String @id @default(cuid())
workspaceId String
name String
instructions String?
scheduleType ScheduleType
scheduleData Json // Flexible storage for schedule details
startDate DateTime?
endDate DateTime?
active Boolean @default(true)
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById String
updatedById String
// Sync tracking
version Int @default(1)
syncedAt DateTime @default(now())
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
createdBy User @relation("MedicationCreatedBy", fields: [createdById], references: [id])
updatedBy User @relation("MedicationUpdatedBy", fields: [updatedById], references: [id])
doseLogs DoseLog[]
@@index([workspaceId, active])
@@index([workspaceId, deletedAt])
@@index([syncedAt])
}
// Schedule data shapes (stored as JSON):
// FIXED_TIMES: { times: ["08:00", "20:00"] }
// INTERVAL: { hours: 8, startTime: "08:00" }
// WEEKDAYS: { days: [1, 3, 5], time: "09:00" } // Monday, Wednesday, Friday
// PRN: { minHoursBetween: 4 }
model DoseLog {
id String @id @default(cuid())
medicationId String
workspaceId String
takenAt DateTime
loggedById String
undoneAt DateTime?
undoneById String?
createdAt DateTime @default(now())
// Sync tracking (append-only, never overwritten)
syncedAt DateTime @default(now())
// Relations
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
loggedBy User @relation("DoseLoggedBy", fields: [loggedById], references: [id])
undoneBy User? @relation("DoseUndoneBy", fields: [undoneById], references: [id])
@@index([medicationId, takenAt])
@@index([workspaceId, takenAt])
@@index([syncedAt])
}
// ============================================
// NOTES
// ============================================
enum NoteType {
QUESTION // Questions for doctor
GENERAL // General notes
}
model Note {
id String @id @default(cuid())
workspaceId String
type NoteType
content String
askedAt DateTime? // When a question was asked (marked)
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById String
updatedById String
// Sync tracking
version Int @default(1)
syncedAt DateTime @default(now())
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
createdBy User @relation("NoteCreatedBy", fields: [createdById], references: [id])
updatedBy User @relation("NoteUpdatedBy", fields: [updatedById], references: [id])
@@index([workspaceId, type])
@@index([workspaceId, deletedAt])
@@index([syncedAt])
}
// ============================================
// AUDIT LOG
// ============================================
model AuditLog {
id String @id @default(cuid())
workspaceId String
userId String
action String // CREATE, UPDATE, DELETE, TAKE_DOSE, UNDO_DOSE, etc.
entityType String // APPOINTMENT, MEDICATION, NOTE, DOSE_LOG
entityId String
details Json? // Additional context
createdAt DateTime @default(now())
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id])
@@index([workspaceId, createdAt])
@@index([entityType, entityId])
}
// ============================================
// SYNC
// ============================================
model SyncCursor {
id String @id @default(cuid())
workspaceId String
cursor BigInt @default(0) // Timestamp-based cursor
updatedAt DateTime @updatedAt
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@unique([workspaceId])
}

24
public/manifest.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "Next Step",
"short_name": "Next Step",
"description": "Health management made simple",
"start_url": "/today",
"display": "standalone",
"background_color": "#fafbfc",
"theme_color": "#3a9563",
"orientation": "portrait-primary",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}

View File

@@ -0,0 +1,146 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { format } from 'date-fns'
import { toZonedTime } from 'date-fns-tz'
import { Button, Input, Textarea, Card, showToast } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { useApp } from '../../provider'
const TIMEZONE = 'Australia/Perth'
export default function NewAppointmentPage() {
const router = useRouter()
const { currentWorkspace, refreshData } = useApp()
const [title, setTitle] = useState('')
const [date, setDate] = useState(format(new Date(), 'yyyy-MM-dd'))
const [time, setTime] = useState('09:00')
const [location, setLocation] = useState('')
const [mapUrl, setMapUrl] = useState('')
const [notes, setNotes] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
// Combine date and time
const datetime = new Date(`${date}T${time}:00`)
const response = await fetch(
`/api/workspaces/${currentWorkspace.id}/appointments`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
datetime: datetime.toISOString(),
location: location || null,
mapUrl: mapUrl || null,
notes: notes || null,
}),
}
)
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Failed to create appointment')
}
await refreshData()
showToast('Appointment added', 'success')
router.push('/appointments')
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong')
} finally {
setLoading(false)
}
}
return (
<>
<Header title="New Appointment" showBack backHref="/appointments" />
<PageContainer className="pt-4">
<Card>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g., Oncology Appointment"
required
/>
<div className="grid grid-cols-2 gap-3">
<Input
label="Date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
required
/>
<Input
label="Time"
type="time"
value={time}
onChange={(e) => setTime(e.target.value)}
required
/>
</div>
<Input
label="Location"
type="text"
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="e.g., Level 3, Cancer Centre"
/>
<Input
label="Map Link (optional)"
type="url"
value={mapUrl}
onChange={(e) => setMapUrl(e.target.value)}
placeholder="https://maps.google.com/..."
helperText="Paste a Google Maps or Apple Maps link"
/>
<Textarea
label="Notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Any notes for this appointment..."
rows={3}
/>
{error && (
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-button">
{error}
</p>
)}
<div className="flex gap-3 pt-2">
<Button
type="button"
variant="secondary"
fullWidth
onClick={() => router.back()}
>
Cancel
</Button>
<Button type="submit" fullWidth loading={loading}>
Save Appointment
</Button>
</div>
</form>
</Card>
</PageContainer>
</>
)
}

View File

@@ -0,0 +1,164 @@
'use client'
import { useRouter } from 'next/navigation'
import { format, isToday, isTomorrow, parseISO, startOfDay } from 'date-fns'
import { toZonedTime } from 'date-fns-tz'
import { Plus, Calendar, MapPin, Clock, ChevronRight } from 'lucide-react'
import { useLiveQuery } from 'dexie-react-hooks'
import { db } from '@/lib/sync'
import { Card, LoadingState, EmptyState } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { useApp } from '../provider'
const TIMEZONE = 'Australia/Perth'
export default function AppointmentsPage() {
const router = useRouter()
const { currentWorkspace } = useApp()
const appointments = useLiveQuery(
() =>
db.appointments
.where('workspaceId')
.equals(currentWorkspace.id)
.and((a) => !a.deletedAt)
.sortBy('datetime'),
[currentWorkspace.id]
)
// Group appointments by date
const groupedAppointments = appointments?.reduce(
(groups, appt) => {
const date = toZonedTime(parseISO(appt.datetime), TIMEZONE)
const dateKey = format(startOfDay(date), 'yyyy-MM-dd')
if (!groups[dateKey]) {
groups[dateKey] = {
date,
appointments: [],
}
}
groups[dateKey].appointments.push(appt)
return groups
},
{} as Record<string, { date: Date; appointments: typeof appointments }>
)
const sortedDates = Object.keys(groupedAppointments || {}).sort()
const formatDateHeader = (date: Date) => {
if (isToday(date)) return 'Today'
if (isTomorrow(date)) return 'Tomorrow'
return format(date, 'EEEE, MMMM d')
}
if (!appointments) {
return (
<>
<Header
title="Appointments"
rightAction={{
icon: <Plus className="w-6 h-6 text-secondary-700" />,
label: 'Add appointment',
onClick: () => router.push('/appointments/new'),
}}
/>
<PageContainer>
<LoadingState message="Loading appointments..." />
</PageContainer>
</>
)
}
return (
<>
<Header
title="Appointments"
rightAction={{
icon: <Plus className="w-6 h-6 text-secondary-700" />,
label: 'Add appointment',
onClick: () => router.push('/appointments/new'),
}}
/>
<PageContainer className="pt-4">
{appointments.length === 0 ? (
<EmptyState
type="appointments"
title="No appointments"
description="Add your upcoming appointments to keep track of them."
action={{
label: 'Add Appointment',
onClick: () => router.push('/appointments/new'),
}}
/>
) : (
<div className="space-y-6">
{sortedDates.map((dateKey) => {
const group = groupedAppointments![dateKey]
const isPast = group.date < startOfDay(new Date())
return (
<div key={dateKey}>
<h2
className={`text-sm font-semibold mb-3 ${
isPast ? 'text-secondary-400' : 'text-secondary-600'
}`}
>
{formatDateHeader(group.date)}
</h2>
<div className="space-y-3">
{group.appointments.map((appt) => (
<Card
key={appt.id}
onClick={() => router.push(`/appointments/${appt.id}`)}
className={`${isPast ? 'opacity-60' : ''}`}
>
<div className="flex items-start gap-3">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${
isPast
? 'bg-secondary-100'
: 'bg-primary-100'
}`}
>
<Calendar
className={`w-5 h-5 ${
isPast
? 'text-secondary-400'
: 'text-primary-600'
}`}
/>
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-secondary-900 truncate">
{appt.title}
</h3>
<p className="text-sm text-secondary-500 flex items-center gap-1 mt-1">
<Clock className="w-4 h-4" />
{format(
toZonedTime(parseISO(appt.datetime), TIMEZONE),
'h:mm a'
)}
</p>
{appt.location && (
<p className="text-sm text-secondary-400 flex items-center gap-1 mt-0.5">
<MapPin className="w-4 h-4" />
<span className="truncate">{appt.location}</span>
</p>
)}
</div>
<ChevronRight className="w-5 h-5 text-secondary-300" />
</div>
</Card>
))}
</div>
</div>
)
})}
</div>
)}
</PageContainer>
</>
)
}

53
src/app/(app)/layout.tsx Normal file
View File

@@ -0,0 +1,53 @@
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { prisma } from '@/lib/db/prisma'
import { BottomNav } from '@/components/layout/bottom-nav'
import { AppProvider } from './provider'
export default async function AppLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await getSession()
if (!session) {
redirect('/login')
}
// Get user's workspaces
const memberships = await prisma.workspaceMember.findMany({
where: { userId: session.user.id },
include: {
workspace: true,
},
orderBy: { createdAt: 'asc' },
})
// If no workspaces, user needs to create one or accept invite
if (memberships.length === 0) {
redirect('/onboarding')
}
const workspaces = memberships.map((m) => ({
id: m.workspace.id,
name: m.workspace.name,
role: m.role,
clinicPhone: m.workspace.clinicPhone,
emergencyPhone: m.workspace.emergencyPhone,
largeTextMode: m.workspace.largeTextMode,
}))
return (
<AppProvider
user={session.user}
workspaces={workspaces}
initialWorkspaceId={workspaces[0].id}
>
<div className={workspaces[0].largeTextMode ? 'large-text' : ''}>
{children}
<BottomNav />
</div>
</AppProvider>
)
}

View File

@@ -0,0 +1,148 @@
'use client'
import { format, isToday, isYesterday, parseISO, startOfDay, subDays } from 'date-fns'
import { toZonedTime } from 'date-fns-tz'
import { Pill, Clock, X } from 'lucide-react'
import { useLiveQuery } from 'dexie-react-hooks'
import { db } from '@/lib/sync'
import { Card, LoadingState, EmptyState } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { useApp } from '../../provider'
const TIMEZONE = 'Australia/Perth'
export default function MedicationHistoryPage() {
const { currentWorkspace } = useApp()
// Get last 7 days of doses
const sevenDaysAgo = subDays(new Date(), 7)
const doseLogs = useLiveQuery(
() =>
db.doseLogs
.where('workspaceId')
.equals(currentWorkspace.id)
.and((d) => new Date(d.takenAt) >= sevenDaysAgo)
.reverse()
.sortBy('takenAt'),
[currentWorkspace.id]
)
// Group by date
const groupedDoses = doseLogs?.reduce(
(groups, dose) => {
const date = toZonedTime(parseISO(dose.takenAt), TIMEZONE)
const dateKey = format(startOfDay(date), 'yyyy-MM-dd')
if (!groups[dateKey]) {
groups[dateKey] = {
date,
doses: [],
}
}
groups[dateKey].doses.push(dose)
return groups
},
{} as Record<string, { date: Date; doses: typeof doseLogs }>
)
const sortedDates = Object.keys(groupedDoses || {}).sort().reverse()
const formatDateHeader = (date: Date) => {
if (isToday(date)) return 'Today'
if (isYesterday(date)) return 'Yesterday'
return format(date, 'EEEE, MMMM d')
}
if (!doseLogs) {
return (
<>
<Header title="Dose History" showBack backHref="/meds" />
<PageContainer>
<LoadingState message="Loading history..." />
</PageContainer>
</>
)
}
return (
<>
<Header title="Dose History" showBack backHref="/meds" />
<PageContainer className="pt-4">
<p className="text-sm text-secondary-500 mb-4">Last 7 days</p>
{doseLogs.length === 0 ? (
<EmptyState
type="medications"
title="No doses recorded"
description="Doses you take will appear here."
/>
) : (
<div className="space-y-6">
{sortedDates.map((dateKey) => {
const group = groupedDoses![dateKey]
return (
<div key={dateKey}>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
{formatDateHeader(group.date)}
</h2>
<Card padding="none">
<div className="divide-y divide-border">
{group.doses.map((dose) => (
<div
key={dose.id}
className={`flex items-center gap-3 p-4 ${
dose.undoneAt ? 'opacity-50' : ''
}`}
>
<div
className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
dose.undoneAt ? 'bg-secondary-100' : 'bg-primary-100'
}`}
>
<Pill
className={`w-4 h-4 ${
dose.undoneAt ? 'text-secondary-400' : 'text-primary-600'
}`}
/>
</div>
<div className="flex-1 min-w-0">
<p
className={`font-medium ${
dose.undoneAt
? 'text-secondary-400 line-through'
: 'text-secondary-900'
}`}
>
{dose.medication?.name || 'Unknown medication'}
</p>
<p className="text-sm text-secondary-500 flex items-center gap-1">
<Clock className="w-3.5 h-3.5" />
{format(
toZonedTime(parseISO(dose.takenAt), TIMEZONE),
'h:mm a'
)}
{dose.loggedBy && `${dose.loggedBy.name}`}
</p>
</div>
{dose.undoneAt && (
<div className="flex items-center gap-1 text-secondary-400">
<X className="w-4 h-4" />
<span className="text-xs">Undone</span>
</div>
)}
</div>
))}
</div>
</Card>
</div>
)
})}
</div>
)}
</PageContainer>
</>
)
}

View File

@@ -0,0 +1,275 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button, Input, Textarea, Select, Card, showToast } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { useApp } from '../../provider'
type ScheduleType = 'FIXED_TIMES' | 'INTERVAL' | 'WEEKDAYS' | 'PRN'
const scheduleTypeOptions = [
{ value: 'FIXED_TIMES', label: 'Fixed times daily' },
{ value: 'INTERVAL', label: 'Every X hours' },
{ value: 'WEEKDAYS', label: 'Specific days of week' },
{ value: 'PRN', label: 'As needed (PRN)' },
]
const weekdays = [
{ value: 0, label: 'Sun' },
{ value: 1, label: 'Mon' },
{ value: 2, label: 'Tue' },
{ value: 3, label: 'Wed' },
{ value: 4, label: 'Thu' },
{ value: 5, label: 'Fri' },
{ value: 6, label: 'Sat' },
]
export default function NewMedicationPage() {
const router = useRouter()
const { currentWorkspace, refreshData } = useApp()
const [name, setName] = useState('')
const [instructions, setInstructions] = useState('')
const [scheduleType, setScheduleType] = useState<ScheduleType>('FIXED_TIMES')
// Fixed times
const [times, setTimes] = useState(['08:00'])
// Interval
const [intervalHours, setIntervalHours] = useState(8)
const [startTime, setStartTime] = useState('08:00')
// Weekdays
const [selectedDays, setSelectedDays] = useState<number[]>([1, 3, 5])
const [weekdayTime, setWeekdayTime] = useState('09:00')
// PRN
const [minHoursBetween, setMinHoursBetween] = useState(4)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const addTime = () => {
setTimes([...times, '12:00'])
}
const removeTime = (index: number) => {
setTimes(times.filter((_, i) => i !== index))
}
const updateTime = (index: number, value: string) => {
const newTimes = [...times]
newTimes[index] = value
setTimes(newTimes)
}
const toggleDay = (day: number) => {
if (selectedDays.includes(day)) {
setSelectedDays(selectedDays.filter((d) => d !== day))
} else {
setSelectedDays([...selectedDays, day].sort())
}
}
const buildScheduleData = () => {
switch (scheduleType) {
case 'FIXED_TIMES':
return { type: 'FIXED_TIMES', times }
case 'INTERVAL':
return { type: 'INTERVAL', hours: intervalHours, startTime }
case 'WEEKDAYS':
return { type: 'WEEKDAYS', days: selectedDays, time: weekdayTime }
case 'PRN':
return { type: 'PRN', minHoursBetween }
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const response = await fetch(
`/api/workspaces/${currentWorkspace.id}/medications`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
instructions: instructions || null,
scheduleType,
scheduleData: buildScheduleData(),
active: true,
}),
}
)
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Failed to add medication')
}
await refreshData()
showToast('Medication added', 'success')
router.push('/meds')
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong')
} finally {
setLoading(false)
}
}
return (
<>
<Header title="New Medication" showBack backHref="/meds" />
<PageContainer className="pt-4">
<Card>
<form onSubmit={handleSubmit} className="space-y-5">
<Input
label="Medication Name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Paracetamol 500mg"
required
/>
<Textarea
label="Instructions (optional)"
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
placeholder="e.g., Take with food"
rows={2}
/>
<Select
label="Schedule Type"
value={scheduleType}
onChange={(e) => setScheduleType(e.target.value as ScheduleType)}
options={scheduleTypeOptions}
/>
{/* Schedule-specific options */}
{scheduleType === 'FIXED_TIMES' && (
<div className="space-y-3">
<label className="block text-sm font-medium text-secondary-700">
Times to take
</label>
{times.map((time, index) => (
<div key={index} className="flex gap-2">
<Input
type="time"
value={time}
onChange={(e) => updateTime(index, e.target.value)}
className="flex-1"
/>
{times.length > 1 && (
<Button
type="button"
variant="ghost"
onClick={() => removeTime(index)}
>
Remove
</Button>
)}
</div>
))}
<Button type="button" variant="secondary" onClick={addTime} size="sm">
+ Add time
</Button>
</div>
)}
{scheduleType === 'INTERVAL' && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<Input
label="Every (hours)"
type="number"
min={1}
max={72}
value={intervalHours}
onChange={(e) => setIntervalHours(parseInt(e.target.value) || 1)}
/>
<Input
label="Starting at"
type="time"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
/>
</div>
</div>
)}
{scheduleType === 'WEEKDAYS' && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-secondary-700 mb-2">
Days
</label>
<div className="flex gap-2 flex-wrap">
{weekdays.map((day) => (
<button
key={day.value}
type="button"
onClick={() => toggleDay(day.value)}
className={`px-3 py-2 rounded-button text-sm font-medium transition-colors ${
selectedDays.includes(day.value)
? 'bg-primary-500 text-white'
: 'bg-muted text-secondary-600 hover:bg-secondary-200'
}`}
>
{day.label}
</button>
))}
</div>
</div>
<Input
label="Time"
type="time"
value={weekdayTime}
onChange={(e) => setWeekdayTime(e.target.value)}
/>
</div>
)}
{scheduleType === 'PRN' && (
<Input
label="Minimum hours between doses"
type="number"
min={0.5}
max={72}
step={0.5}
value={minHoursBetween}
onChange={(e) => setMinHoursBetween(parseFloat(e.target.value) || 4)}
helperText="Shows 'Available' when enough time has passed since last dose"
/>
)}
{error && (
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-button">
{error}
</p>
)}
<div className="flex gap-3 pt-2">
<Button
type="button"
variant="secondary"
fullWidth
onClick={() => router.back()}
>
Cancel
</Button>
<Button type="submit" fullWidth loading={loading}>
Save Medication
</Button>
</div>
</form>
</Card>
</PageContainer>
</>
)
}

258
src/app/(app)/meds/page.tsx Normal file
View File

@@ -0,0 +1,258 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { Plus, Pill, Clock, ChevronRight, History } from 'lucide-react'
import { useLiveQuery } from 'dexie-react-hooks'
import { db, logDose, undoDose } from '@/lib/sync'
import { calculateAllMedicationsDue, formatTimeUntil } from '@/lib/schedule'
import type { Medication, DoseLog, MedicationDueStatus } from '@/lib/schedule'
import { Card, Button, LoadingState, EmptyState, showUndoToast, showToast } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { useApp } from '../provider'
export default function MedsPage() {
const router = useRouter()
const { currentWorkspace } = useApp()
const [now, setNow] = useState(() => new Date())
// Update time every minute
useEffect(() => {
const interval = setInterval(() => setNow(new Date()), 60000)
return () => clearInterval(interval)
}, [])
// Fetch data from IndexedDB
const medications = useLiveQuery(
() =>
db.medications
.where('workspaceId')
.equals(currentWorkspace.id)
.and((m) => !m.deletedAt)
.toArray(),
[currentWorkspace.id]
)
const doseLogs = useLiveQuery(
() =>
db.doseLogs
.where('workspaceId')
.equals(currentWorkspace.id)
.toArray(),
[currentWorkspace.id]
)
// Calculate medication due statuses
const [medStatuses, setMedStatuses] = useState<MedicationDueStatus[]>([])
useEffect(() => {
if (medications && doseLogs) {
const meds = medications
.filter((m) => m.active)
.map((m) => ({
...m,
scheduleData: m.scheduleData as Medication['scheduleData'],
startDate: m.startDate ? new Date(m.startDate) : null,
endDate: m.endDate ? new Date(m.endDate) : null,
})) as Medication[]
const logs = doseLogs.map((d) => ({
...d,
takenAt: new Date(d.takenAt),
undoneAt: d.undoneAt ? new Date(d.undoneAt) : null,
})) as DoseLog[]
const statuses = calculateAllMedicationsDue(meds, now, logs)
setMedStatuses(statuses)
}
}, [medications, doseLogs, now])
// Inactive medications
const inactiveMeds = medications?.filter((m) => !m.active) || []
const handleTakeMed = useCallback(
async (status: MedicationDueStatus) => {
try {
const doseLog = await logDose(
currentWorkspace.id,
status.medication.id,
{ id: status.medication.id, name: status.medication.name }
)
showUndoToast(`Took ${status.medication.name}`, async () => {
await undoDose(doseLog)
showToast('Dose undone', 'info')
})
} catch {
showToast('Failed to log dose', 'error')
}
},
[currentWorkspace.id]
)
if (!medications) {
return (
<>
<Header
title="Medications"
rightAction={{
icon: <Plus className="w-6 h-6 text-secondary-700" />,
label: 'Add medication',
onClick: () => router.push('/meds/new'),
}}
/>
<PageContainer>
<LoadingState message="Loading medications..." />
</PageContainer>
</>
)
}
return (
<>
<Header
title="Medications"
rightAction={{
icon: <Plus className="w-6 h-6 text-secondary-700" />,
label: 'Add medication',
onClick: () => router.push('/meds/new'),
}}
/>
<PageContainer className="pt-4">
{/* History link */}
<button
onClick={() => router.push('/meds/history')}
className="flex items-center gap-2 text-primary-600 font-medium mb-4"
>
<History className="w-4 h-4" />
View dose history
</button>
{medications.length === 0 ? (
<EmptyState
type="medications"
title="No medications"
description="Add medications you need to track."
action={{
label: 'Add Medication',
onClick: () => router.push('/meds/new'),
}}
/>
) : (
<div className="space-y-6">
{/* Active medications */}
{medStatuses.length > 0 && (
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
Active Medications
</h2>
<div className="space-y-3">
{medStatuses.map((status) => (
<MedicationCard
key={status.medication.id}
status={status}
now={now}
onTake={() => handleTakeMed(status)}
onClick={() => router.push(`/meds/${status.medication.id}`)}
/>
))}
</div>
</section>
)}
{/* Inactive medications */}
{inactiveMeds.length > 0 && (
<section>
<h2 className="text-sm font-semibold text-secondary-400 mb-3">
Inactive Medications
</h2>
<div className="space-y-3">
{inactiveMeds.map((med) => (
<Card
key={med.id}
onClick={() => router.push(`/meds/${med.id}`)}
className="opacity-60"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-secondary-100 flex items-center justify-center flex-shrink-0">
<Pill className="w-5 h-5 text-secondary-400" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-secondary-700">{med.name}</h3>
<p className="text-sm text-secondary-400">Inactive</p>
</div>
<ChevronRight className="w-5 h-5 text-secondary-300" />
</div>
</Card>
))}
</div>
</section>
)}
</div>
)}
</PageContainer>
</>
)
}
interface MedicationCardProps {
status: MedicationDueStatus
now: Date
onTake: () => void
onClick: () => void
}
function MedicationCard({ status, now, onTake, onClick }: MedicationCardProps) {
const { medication, isOverdue, isPRN, prnAvailable, prnAvailableAt, nextDueAt } = status
const getTimeLabel = () => {
if (isOverdue && nextDueAt) {
return formatTimeUntil(nextDueAt, now)
}
if (isPRN) {
if (prnAvailable) {
return 'Available now'
}
if (prnAvailableAt) {
return `Available ${formatTimeUntil(prnAvailableAt, now)}`
}
return 'As needed'
}
if (nextDueAt) {
return formatTimeUntil(nextDueAt, now)
}
return ''
}
const canTake = !isPRN || prnAvailable
return (
<Card className={isOverdue ? 'overdue' : ''} onClick={onClick}>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center flex-shrink-0">
<Pill className="w-5 h-5 text-primary-600" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-secondary-900">{medication.name}</h3>
<p className={`text-sm flex items-center gap-1 ${isOverdue ? 'text-red-600 font-medium' : 'text-secondary-500'}`}>
<Clock className="w-3.5 h-3.5" />
{getTimeLabel()}
{isPRN && ' • PRN'}
</p>
</div>
<Button
onClick={(e) => {
e.stopPropagation()
onTake()
}}
variant="success"
size="md"
disabled={!canTake}
>
Taken
</Button>
</div>
</Card>
)
}

View File

@@ -0,0 +1,289 @@
'use client'
import { useState, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { format, parseISO } from 'date-fns'
import { toZonedTime } from 'date-fns-tz'
import {
Plus,
HelpCircle,
FileText,
CheckCircle,
ChevronRight,
Copy,
} from 'lucide-react'
import { useLiveQuery } from 'dexie-react-hooks'
import { db, createLocalNote, markQuestionAsked } from '@/lib/sync'
import { Card, Button, LoadingState, EmptyState, Modal, Textarea, showToast } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { useApp } from '../provider'
const TIMEZONE = 'Australia/Perth'
export default function NotesPage() {
const router = useRouter()
const { currentWorkspace, refreshData } = useApp()
const [showNewNote, setShowNewNote] = useState(false)
const [noteType, setNoteType] = useState<'QUESTION' | 'GENERAL'>('GENERAL')
const [noteContent, setNoteContent] = useState('')
const [loading, setLoading] = useState(false)
const notes = useLiveQuery(
() =>
db.notes
.where('workspaceId')
.equals(currentWorkspace.id)
.and((n) => !n.deletedAt)
.reverse()
.sortBy('createdAt'),
[currentWorkspace.id]
)
const questions = notes?.filter((n) => n.type === 'QUESTION') || []
const generalNotes = notes?.filter((n) => n.type === 'GENERAL') || []
const unansweredQuestions = questions.filter((q) => !q.askedAt)
const handleAddNote = async () => {
if (!noteContent.trim()) return
setLoading(true)
try {
await createLocalNote(currentWorkspace.id, {
type: noteType,
content: noteContent.trim(),
})
await refreshData()
setNoteContent('')
setShowNewNote(false)
showToast(noteType === 'QUESTION' ? 'Question added' : 'Note added', 'success')
} catch {
showToast('Failed to add note', 'error')
} finally {
setLoading(false)
}
}
const handleMarkAsked = useCallback(
async (noteId: string) => {
const note = notes?.find((n) => n.id === noteId)
if (!note) return
try {
await markQuestionAsked(note)
await refreshData()
showToast('Marked as asked', 'success')
} catch {
showToast('Failed to update', 'error')
}
},
[notes, refreshData]
)
if (!notes) {
return (
<>
<Header
title="Notes"
rightAction={{
icon: <Plus className="w-6 h-6 text-secondary-700" />,
label: 'Add note',
onClick: () => setShowNewNote(true),
}}
/>
<PageContainer>
<LoadingState message="Loading notes..." />
</PageContainer>
</>
)
}
return (
<>
<Header
title="Notes"
rightAction={{
icon: <Plus className="w-6 h-6 text-secondary-700" />,
label: 'Add note',
onClick: () => setShowNewNote(true),
}}
/>
<PageContainer className="pt-4">
{/* Quick links */}
<div className="flex gap-3 mb-6">
<button
onClick={() => router.push('/notes/questions')}
className="flex-1 flex items-center gap-2 p-3 bg-amber-50 rounded-card border border-amber-100 hover:bg-amber-100 transition-colors"
>
<HelpCircle className="w-5 h-5 text-amber-600" />
<span className="font-medium text-amber-800">
Questions ({unansweredQuestions.length})
</span>
</button>
<button
onClick={() => {
setNoteType('QUESTION')
setShowNewNote(true)
}}
className="flex items-center gap-2 p-3 bg-primary-50 rounded-card border border-primary-100 hover:bg-primary-100 transition-colors"
>
<Plus className="w-5 h-5 text-primary-600" />
</button>
</div>
{notes.length === 0 ? (
<EmptyState
type="notes"
title="No notes yet"
description="Add questions for your doctor or general notes."
action={{
label: 'Add Note',
onClick: () => setShowNewNote(true),
}}
/>
) : (
<div className="space-y-6">
{/* Questions for doctor */}
{questions.length > 0 && (
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-secondary-600">
Questions for Doctor
</h2>
<button
onClick={() => router.push('/notes/questions')}
className="text-sm text-primary-600 font-medium"
>
View all
</button>
</div>
<div className="space-y-2">
{questions.slice(0, 3).map((note) => (
<Card key={note.id} padding="sm">
<div className="flex items-start gap-3">
<div
className={`w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5 ${
note.askedAt ? 'bg-green-100' : 'bg-amber-100'
}`}
>
{note.askedAt ? (
<CheckCircle className="w-4 h-4 text-green-600" />
) : (
<HelpCircle className="w-4 h-4 text-amber-600" />
)}
</div>
<div className="flex-1 min-w-0">
<p
className={`text-secondary-800 ${
note.askedAt ? 'line-through opacity-60' : ''
}`}
>
{note.content}
</p>
</div>
{!note.askedAt && (
<Button
variant="ghost"
size="sm"
onClick={() => handleMarkAsked(note.id)}
>
Asked
</Button>
)}
</div>
</Card>
))}
</div>
</section>
)}
{/* General notes */}
{generalNotes.length > 0 && (
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
General Notes
</h2>
<div className="space-y-2">
{generalNotes.map((note) => (
<Card key={note.id} padding="sm">
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-secondary-100 flex items-center justify-center flex-shrink-0 mt-0.5">
<FileText className="w-4 h-4 text-secondary-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-secondary-800">{note.content}</p>
<p className="text-xs text-secondary-400 mt-1">
{format(
toZonedTime(parseISO(note.createdAt || ''), TIMEZONE),
'MMM d, h:mm a'
)}
</p>
</div>
</div>
</Card>
))}
</div>
</section>
)}
</div>
)}
{/* New note modal */}
<Modal
isOpen={showNewNote}
onClose={() => {
setShowNewNote(false)
setNoteContent('')
}}
title={noteType === 'QUESTION' ? 'New Question' : 'New Note'}
>
<div className="space-y-4">
<div className="flex gap-2">
<button
onClick={() => setNoteType('GENERAL')}
className={`flex-1 py-2 px-3 rounded-button text-sm font-medium transition-colors ${
noteType === 'GENERAL'
? 'bg-primary-500 text-white'
: 'bg-muted text-secondary-600'
}`}
>
General Note
</button>
<button
onClick={() => setNoteType('QUESTION')}
className={`flex-1 py-2 px-3 rounded-button text-sm font-medium transition-colors ${
noteType === 'QUESTION'
? 'bg-amber-500 text-white'
: 'bg-muted text-secondary-600'
}`}
>
Question
</button>
</div>
<Textarea
value={noteContent}
onChange={(e) => setNoteContent(e.target.value)}
placeholder={
noteType === 'QUESTION'
? 'What do you want to ask the doctor?'
: 'Write your note...'
}
rows={4}
autoFocus
/>
<Button
onClick={handleAddNote}
fullWidth
loading={loading}
disabled={!noteContent.trim()}
>
Add {noteType === 'QUESTION' ? 'Question' : 'Note'}
</Button>
</div>
</Modal>
</PageContainer>
</>
)
}

View File

@@ -0,0 +1,158 @@
'use client'
import { useCallback } from 'react'
import { format, parseISO } from 'date-fns'
import { toZonedTime } from 'date-fns-tz'
import { HelpCircle, CheckCircle, Copy } from 'lucide-react'
import { useLiveQuery } from 'dexie-react-hooks'
import { db, markQuestionAsked } from '@/lib/sync'
import { Card, Button, LoadingState, EmptyState, showToast } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { useApp } from '../../provider'
const TIMEZONE = 'Australia/Perth'
export default function QuestionsPage() {
const { currentWorkspace, refreshData } = useApp()
const questions = useLiveQuery(
() =>
db.notes
.where('workspaceId')
.equals(currentWorkspace.id)
.and((n) => n.type === 'QUESTION' && !n.deletedAt)
.reverse()
.sortBy('createdAt'),
[currentWorkspace.id]
)
const unanswered = questions?.filter((q) => !q.askedAt) || []
const answered = questions?.filter((q) => q.askedAt) || []
const handleMarkAsked = useCallback(
async (noteId: string) => {
const note = questions?.find((n) => n.id === noteId)
if (!note) return
try {
await markQuestionAsked(note)
await refreshData()
showToast('Marked as asked', 'success')
} catch {
showToast('Failed to update', 'error')
}
},
[questions, refreshData]
)
const copyAllQuestions = () => {
const text = unanswered.map((q) => `${q.content}`).join('\n')
navigator.clipboard.writeText(text)
showToast('Questions copied', 'success')
}
if (!questions) {
return (
<>
<Header title="Questions" showBack backHref="/notes" />
<PageContainer>
<LoadingState message="Loading questions..." />
</PageContainer>
</>
)
}
return (
<>
<Header title="Questions for Doctor" showBack backHref="/notes" />
<PageContainer className="pt-4">
{questions.length === 0 ? (
<EmptyState
type="notes"
title="No questions"
description="Add questions to ask your doctor at the next appointment."
/>
) : (
<div className="space-y-6">
{/* Unanswered */}
{unanswered.length > 0 && (
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-secondary-600">
To Ask ({unanswered.length})
</h2>
<button
onClick={copyAllQuestions}
className="flex items-center gap-1 text-sm text-primary-600 font-medium"
>
<Copy className="w-4 h-4" />
Copy all
</button>
</div>
<div className="space-y-2">
{unanswered.map((note) => (
<Card key={note.id}>
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-amber-100 flex items-center justify-center flex-shrink-0">
<HelpCircle className="w-5 h-5 text-amber-600" />
</div>
<div className="flex-1 min-w-0">
<p className="text-secondary-800">{note.content}</p>
<p className="text-xs text-secondary-400 mt-1">
Added{' '}
{format(
toZonedTime(parseISO(note.createdAt || ''), TIMEZONE),
'MMM d'
)}
</p>
</div>
<Button
variant="success"
size="sm"
onClick={() => handleMarkAsked(note.id)}
>
Asked
</Button>
</div>
</Card>
))}
</div>
</section>
)}
{/* Answered */}
{answered.length > 0 && (
<section>
<h2 className="text-sm font-semibold text-secondary-400 mb-3">
Asked ({answered.length})
</h2>
<div className="space-y-2">
{answered.map((note) => (
<Card key={note.id} className="opacity-60">
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-green-100 flex items-center justify-center flex-shrink-0">
<CheckCircle className="w-5 h-5 text-green-600" />
</div>
<div className="flex-1 min-w-0">
<p className="text-secondary-600 line-through">{note.content}</p>
<p className="text-xs text-secondary-400 mt-1">
Asked{' '}
{format(
toZonedTime(parseISO(note.askedAt || ''), TIMEZONE),
'MMM d'
)}
</p>
</div>
</div>
</Card>
))}
</div>
</section>
)}
</div>
)}
</PageContainer>
</>
)
}

View File

@@ -0,0 +1,86 @@
'use client'
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
import { startAutoSync, stopAutoSync, sync } from '@/lib/sync'
interface User {
id: string
email: string
name: string
}
interface Workspace {
id: string
name: string
role: string
clinicPhone: string | null
emergencyPhone: string | null
largeTextMode: boolean
}
interface AppContextType {
user: User
workspaces: Workspace[]
currentWorkspace: Workspace
setCurrentWorkspaceId: (id: string) => void
refreshData: () => Promise<void>
}
const AppContext = createContext<AppContextType | null>(null)
export function useApp() {
const context = useContext(AppContext)
if (!context) {
throw new Error('useApp must be used within AppProvider')
}
return context
}
interface AppProviderProps {
children: React.ReactNode
user: User
workspaces: Workspace[]
initialWorkspaceId: string
}
export function AppProvider({
children,
user,
workspaces,
initialWorkspaceId,
}: AppProviderProps) {
const [currentWorkspaceId, setCurrentWorkspaceId] = useState(initialWorkspaceId)
const currentWorkspace = workspaces.find((w) => w.id === currentWorkspaceId) || workspaces[0]
// Start auto-sync when workspace changes
useEffect(() => {
if (currentWorkspaceId) {
startAutoSync(currentWorkspaceId)
}
return () => {
stopAutoSync()
}
}, [currentWorkspaceId])
const refreshData = useCallback(async () => {
if (currentWorkspaceId) {
await sync(currentWorkspaceId)
}
}, [currentWorkspaceId])
return (
<AppContext.Provider
value={{
user,
workspaces,
currentWorkspace,
setCurrentWorkspaceId,
refreshData,
}}
>
{children}
</AppContext.Provider>
)
}

View File

@@ -0,0 +1,84 @@
'use client'
import { Shield, Phone, AlertTriangle } from 'lucide-react'
import { Card } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
export default function DisclaimerPage() {
return (
<>
<Header title="Disclaimer" showBack backHref="/settings" />
<PageContainer className="pt-4 space-y-6">
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-amber-100 flex items-center justify-center">
<Shield className="w-5 h-5 text-amber-600" />
</div>
<h2 className="text-lg font-semibold text-secondary-900">
Important Information
</h2>
</div>
<div className="space-y-4 text-secondary-700">
<p>
<strong>Next Step is a tracking and organizational tool only.</strong> It is
designed to help you and your family keep track of appointments, medications,
and notes during your healthcare journey.
</p>
<div className="bg-red-50 border border-red-100 rounded-card p-4">
<div className="flex gap-3">
<AlertTriangle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
<div>
<p className="font-semibold text-red-800">
This app does not provide medical advice.
</p>
<p className="text-red-700 text-sm mt-1">
Never use this app to make medical decisions. Always consult your
healthcare team for any questions about your treatment.
</p>
</div>
</div>
</div>
<p>
The medication reminders in this app are for tracking purposes only. They are
not a substitute for professional medical advice, and the app does not verify
the accuracy of medication schedules.
</p>
<p>
<strong>If you experience a medical emergency:</strong>
</p>
<ul className="list-disc list-inside space-y-1 ml-2">
<li>Call 000 (Australia) or your local emergency services</li>
<li>Go to your nearest emergency department</li>
<li>Contact your healthcare team directly</li>
</ul>
<div className="bg-primary-50 border border-primary-100 rounded-card p-4">
<div className="flex gap-3">
<Phone className="w-5 h-5 text-primary-600 flex-shrink-0 mt-0.5" />
<div>
<p className="font-semibold text-primary-800">
Questions about your treatment?
</p>
<p className="text-primary-700 text-sm mt-1">
Use the "Call Clinic" button on the Today screen to contact your
healthcare team directly.
</p>
</div>
</div>
</div>
<p className="text-sm text-secondary-500 pt-4 border-t border-border">
By using Next Step, you acknowledge that this application is for organizational
purposes only and does not replace professional medical advice, diagnosis, or
treatment.
</p>
</div>
</Card>
</PageContainer>
</>
)
}

View File

@@ -0,0 +1,417 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import {
Phone,
Users,
Type,
Download,
LogOut,
ChevronRight,
Shield,
ExternalLink,
Copy,
} from 'lucide-react'
import { useLiveQuery } from 'dexie-react-hooks'
import { db } from '@/lib/sync'
import { Card, Button, Input, Modal, showToast } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { useApp } from '../provider'
export default function SettingsPage() {
const router = useRouter()
const { user, currentWorkspace, workspaces, refreshData } = useApp()
const [showPhoneEdit, setShowPhoneEdit] = useState(false)
const [clinicPhone, setClinicPhone] = useState(currentWorkspace.clinicPhone || '')
const [emergencyPhone, setEmergencyPhone] = useState(currentWorkspace.emergencyPhone || '')
const [saving, setSaving] = useState(false)
const [showInvite, setShowInvite] = useState(false)
const [inviteLoading, setInviteLoading] = useState(false)
const [inviteUrl, setInviteUrl] = useState('')
const [inviteRole, setInviteRole] = useState<'EDITOR' | 'VIEWER'>('VIEWER')
// Get workspace from IndexedDB for large text mode
const workspace = useLiveQuery(
() => db.workspaces.get(currentWorkspace.id),
[currentWorkspace.id]
)
const handleUpdatePhones = async () => {
setSaving(true)
try {
const response = await fetch(`/api/workspaces/${currentWorkspace.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
clinicPhone: clinicPhone.trim() || null,
emergencyPhone: emergencyPhone.trim() || null,
}),
})
if (!response.ok) throw new Error('Failed to update')
await refreshData()
setShowPhoneEdit(false)
showToast('Phone numbers updated', 'success')
} catch {
showToast('Failed to save', 'error')
} finally {
setSaving(false)
}
}
const handleToggleLargeText = async () => {
try {
const response = await fetch(`/api/workspaces/${currentWorkspace.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
largeTextMode: !currentWorkspace.largeTextMode,
}),
})
if (!response.ok) throw new Error('Failed to update')
await refreshData()
window.location.reload()
} catch {
showToast('Failed to update', 'error')
}
}
const handleCreateInvite = async () => {
setInviteLoading(true)
try {
const response = await fetch(`/api/workspaces/${currentWorkspace.id}/invite`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: inviteRole, expiresInDays: 7 }),
})
const data = await response.json()
if (!response.ok) throw new Error(data.error)
setInviteUrl(data.invite.url)
} catch (err) {
showToast(err instanceof Error ? err.message : 'Failed to create invite', 'error')
} finally {
setInviteLoading(false)
}
}
const copyInviteLink = () => {
navigator.clipboard.writeText(inviteUrl)
showToast('Link copied!', 'success')
}
const handleLogout = async () => {
try {
await fetch('/api/auth/logout', { method: 'POST' })
router.push('/login')
router.refresh()
} catch {
showToast('Failed to log out', 'error')
}
}
const handleExportJSON = async () => {
try {
// Fetch all data
const [appointments, medications, notes, doseLogs] = await Promise.all([
db.appointments.where('workspaceId').equals(currentWorkspace.id).toArray(),
db.medications.where('workspaceId').equals(currentWorkspace.id).toArray(),
db.notes.where('workspaceId').equals(currentWorkspace.id).toArray(),
db.doseLogs.where('workspaceId').equals(currentWorkspace.id).toArray(),
])
const exportData = {
exportedAt: new Date().toISOString(),
workspace: currentWorkspace.name,
appointments: appointments.filter((a) => !a.deletedAt),
medications: medications.filter((m) => !m.deletedAt),
notes: notes.filter((n) => !n.deletedAt),
doseLogs: doseLogs.filter((d) => !d.undoneAt),
}
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
type: 'application/json',
})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `nextstep-export-${new Date().toISOString().split('T')[0]}.json`
a.click()
URL.revokeObjectURL(url)
showToast('Data exported', 'success')
} catch {
showToast('Export failed', 'error')
}
}
return (
<>
<Header title="Settings" />
<PageContainer className="pt-4 space-y-6">
{/* Workspace info */}
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
Workspace
</h2>
<Card>
<p className="font-semibold text-secondary-900">{currentWorkspace.name}</p>
<p className="text-sm text-secondary-500 capitalize mt-1">
You're the {currentWorkspace.role.toLowerCase()}
</p>
</Card>
</section>
{/* Contact numbers */}
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
Contact Numbers
</h2>
<Card padding="none">
<button
onClick={() => setShowPhoneEdit(true)}
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
>
<Phone className="w-5 h-5 text-secondary-500" />
<div className="flex-1 text-left">
<p className="font-medium text-secondary-900">Clinic</p>
<p className="text-sm text-secondary-500">
{currentWorkspace.clinicPhone || 'Not set'}
</p>
</div>
<ChevronRight className="w-5 h-5 text-secondary-300" />
</button>
<div className="border-t border-border">
<button
onClick={() => setShowPhoneEdit(true)}
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
>
<Phone className="w-5 h-5 text-secondary-500" />
<div className="flex-1 text-left">
<p className="font-medium text-secondary-900">Emergency Contact</p>
<p className="text-sm text-secondary-500">
{currentWorkspace.emergencyPhone || 'Not set'}
</p>
</div>
<ChevronRight className="w-5 h-5 text-secondary-300" />
</button>
</div>
</Card>
</section>
{/* Family members */}
{currentWorkspace.role === 'OWNER' && (
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
Family Access
</h2>
<Card padding="none">
<button
onClick={() => setShowInvite(true)}
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
>
<Users className="w-5 h-5 text-secondary-500" />
<div className="flex-1 text-left">
<p className="font-medium text-secondary-900">Invite Family Member</p>
<p className="text-sm text-secondary-500">Share access to this workspace</p>
</div>
<ChevronRight className="w-5 h-5 text-secondary-300" />
</button>
</Card>
</section>
)}
{/* Accessibility */}
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
Accessibility
</h2>
<Card padding="none">
<button
onClick={handleToggleLargeText}
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
>
<Type className="w-5 h-5 text-secondary-500" />
<div className="flex-1 text-left">
<p className="font-medium text-secondary-900">Large Text</p>
<p className="text-sm text-secondary-500">
{currentWorkspace.largeTextMode ? 'Enabled' : 'Disabled'}
</p>
</div>
<div
className={`w-12 h-7 rounded-full transition-colors ${
currentWorkspace.largeTextMode ? 'bg-primary-500' : 'bg-secondary-300'
}`}
>
<div
className={`w-5 h-5 rounded-full bg-white shadow-sm mt-1 transition-transform ${
currentWorkspace.largeTextMode ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</div>
</button>
</Card>
</section>
{/* Data */}
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">Data</h2>
<Card padding="none">
<button
onClick={handleExportJSON}
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
>
<Download className="w-5 h-5 text-secondary-500" />
<div className="flex-1 text-left">
<p className="font-medium text-secondary-900">Export Data</p>
<p className="text-sm text-secondary-500">Download as JSON</p>
</div>
<ChevronRight className="w-5 h-5 text-secondary-300" />
</button>
</Card>
</section>
{/* Legal */}
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">About</h2>
<Card padding="none">
<button
onClick={() => router.push('/settings/disclaimer')}
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
>
<Shield className="w-5 h-5 text-secondary-500" />
<div className="flex-1 text-left">
<p className="font-medium text-secondary-900">Disclaimer</p>
<p className="text-sm text-secondary-500">Important information</p>
</div>
<ChevronRight className="w-5 h-5 text-secondary-300" />
</button>
</Card>
</section>
{/* Account */}
<section>
<h2 className="text-sm font-semibold text-secondary-600 mb-3">Account</h2>
<Card>
<p className="text-secondary-700">{user.name}</p>
<p className="text-sm text-secondary-500">{user.email}</p>
<Button
variant="ghost"
className="mt-4 text-red-600 hover:bg-red-50"
onClick={handleLogout}
>
<LogOut className="w-4 h-4 mr-2" />
Sign Out
</Button>
</Card>
</section>
</PageContainer>
{/* Phone edit modal */}
<Modal
isOpen={showPhoneEdit}
onClose={() => setShowPhoneEdit(false)}
title="Contact Numbers"
>
<div className="space-y-4">
<Input
label="Clinic Phone"
type="tel"
value={clinicPhone}
onChange={(e) => setClinicPhone(e.target.value)}
placeholder="e.g., 08 9400 1234"
/>
<Input
label="Emergency Contact"
type="tel"
value={emergencyPhone}
onChange={(e) => setEmergencyPhone(e.target.value)}
placeholder="e.g., 0412 345 678"
/>
<Button onClick={handleUpdatePhones} fullWidth loading={saving}>
Save
</Button>
</div>
</Modal>
{/* Invite modal */}
<Modal
isOpen={showInvite}
onClose={() => {
setShowInvite(false)
setInviteUrl('')
}}
title="Invite Family Member"
>
{!inviteUrl ? (
<div className="space-y-4">
<p className="text-secondary-600">
Create an invite link to share with a family member. They'll be able to
view and help manage {currentWorkspace.name}.
</p>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-2">
Permission Level
</label>
<div className="flex gap-2">
<button
onClick={() => setInviteRole('VIEWER')}
className={`flex-1 py-2 px-3 rounded-button text-sm font-medium transition-colors ${
inviteRole === 'VIEWER'
? 'bg-primary-500 text-white'
: 'bg-muted text-secondary-600'
}`}
>
Viewer
</button>
<button
onClick={() => setInviteRole('EDITOR')}
className={`flex-1 py-2 px-3 rounded-button text-sm font-medium transition-colors ${
inviteRole === 'EDITOR'
? 'bg-primary-500 text-white'
: 'bg-muted text-secondary-600'
}`}
>
Editor
</button>
</div>
<p className="text-xs text-secondary-500 mt-1">
{inviteRole === 'VIEWER'
? 'Can view everything but not make changes'
: 'Can add appointments, log doses, and add notes'}
</p>
</div>
<Button onClick={handleCreateInvite} fullWidth loading={inviteLoading}>
Create Invite Link
</Button>
</div>
) : (
<div className="space-y-4">
<p className="text-secondary-600">
Share this link with your family member. It expires in 7 days.
</p>
<div className="bg-muted p-3 rounded-button break-all text-sm text-secondary-700">
{inviteUrl}
</div>
<Button onClick={copyInviteLink} fullWidth>
<Copy className="w-4 h-4 mr-2" />
Copy Link
</Button>
</div>
)}
</Modal>
</>
)
}

View File

@@ -0,0 +1,397 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { format, isToday, isTomorrow } from 'date-fns'
import { toZonedTime } from 'date-fns-tz'
import { Phone, MapPin, Clock, ChevronRight, Pill, Calendar, Plus } from 'lucide-react'
import { useLiveQuery } from 'dexie-react-hooks'
import { db, logDose, undoDose } from '@/lib/sync'
import { calculateAllMedicationsDue, formatTimeUntil } from '@/lib/schedule'
import type { Medication, DoseLog, MedicationDueStatus } from '@/lib/schedule'
import { Card, CardTitle, Button, LoadingState, EmptyState, showUndoToast, showToast } from '@/components/ui'
import { Header, PageContainer } from '@/components/layout/header'
import { useApp } from '../provider'
const TIMEZONE = 'Australia/Perth'
export default function TodayPage() {
const router = useRouter()
const { currentWorkspace, refreshData } = useApp()
const [now, setNow] = useState(() => new Date())
const [quickNote, setQuickNote] = useState('')
const [isAddingNote, setIsAddingNote] = useState(false)
// Update time every minute
useEffect(() => {
const interval = setInterval(() => setNow(new Date()), 60000)
return () => clearInterval(interval)
}, [])
// Fetch data from IndexedDB
const appointments = useLiveQuery(
() =>
db.appointments
.where('workspaceId')
.equals(currentWorkspace.id)
.and((a) => !a.deletedAt && new Date(a.datetime) >= now)
.sortBy('datetime'),
[currentWorkspace.id, now]
)
const medications = useLiveQuery(
() =>
db.medications
.where('workspaceId')
.equals(currentWorkspace.id)
.and((m) => m.active && !m.deletedAt)
.toArray(),
[currentWorkspace.id]
)
const doseLogs = useLiveQuery(
() =>
db.doseLogs
.where('workspaceId')
.equals(currentWorkspace.id)
.toArray(),
[currentWorkspace.id]
)
// Calculate medication due statuses
const [medStatuses, setMedStatuses] = useState<MedicationDueStatus[]>([])
useEffect(() => {
if (medications && doseLogs) {
const meds = medications.map((m) => ({
...m,
scheduleData: m.scheduleData as Medication['scheduleData'],
startDate: m.startDate ? new Date(m.startDate) : null,
endDate: m.endDate ? new Date(m.endDate) : null,
})) as Medication[]
const logs = doseLogs.map((d) => ({
...d,
takenAt: new Date(d.takenAt),
undoneAt: d.undoneAt ? new Date(d.undoneAt) : null,
})) as DoseLog[]
const statuses = calculateAllMedicationsDue(meds, now, logs)
setMedStatuses(statuses)
}
}, [medications, doseLogs, now])
// Get next appointment
const nextAppointment = appointments?.[0]
// Get meds due soon (due within 2 hours or overdue)
const medsDueSoon = medStatuses
.filter((s) => {
if (s.isOverdue) return true
if (s.isPRN && s.prnAvailable) return true
if (s.nextDueAt) {
const minutesUntil = (s.nextDueAt.getTime() - now.getTime()) / 1000 / 60
return minutesUntil <= 120
}
return false
})
.slice(0, 5)
const handleTakeMed = useCallback(
async (status: MedicationDueStatus) => {
try {
const doseLog = await logDose(
currentWorkspace.id,
status.medication.id,
{ id: status.medication.id, name: status.medication.name }
)
showUndoToast(`Took ${status.medication.name}`, async () => {
await undoDose(doseLog)
showToast('Dose undone', 'info')
})
} catch {
showToast('Failed to log dose', 'error')
}
},
[currentWorkspace.id]
)
const handleAddQuickNote = async () => {
if (!quickNote.trim()) return
setIsAddingNote(true)
try {
const response = await fetch(`/api/workspaces/${currentWorkspace.id}/notes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'GENERAL',
content: quickNote.trim(),
}),
})
if (!response.ok) throw new Error('Failed to add note')
setQuickNote('')
showToast('Note added', 'success')
await refreshData()
} catch {
showToast('Failed to add note', 'error')
} finally {
setIsAddingNote(false)
}
}
const formatAppointmentDate = (datetime: string) => {
const date = toZonedTime(new Date(datetime), TIMEZONE)
if (isToday(date)) return `Today at ${format(date, 'h:mm a')}`
if (isTomorrow(date)) return `Tomorrow at ${format(date, 'h:mm a')}`
return format(date, 'EEE, MMM d \'at\' h:mm a')
}
if (!appointments || !medications) {
return (
<>
<Header title="Today" />
<PageContainer>
<LoadingState message="Loading your day..." />
</PageContainer>
</>
)
}
return (
<>
<Header title="Today" />
<PageContainer className="pt-4 space-y-6">
{/* Greeting */}
<div className="mb-2">
<p className="text-secondary-500 text-sm">
{format(toZonedTime(now, TIMEZONE), 'EEEE, MMMM d')}
</p>
</div>
{/* Call Clinic Button */}
{currentWorkspace.clinicPhone && (
<a
href={`tel:${currentWorkspace.clinicPhone}`}
className="flex items-center gap-3 p-4 bg-primary-50 rounded-card border border-primary-100 hover:bg-primary-100 transition-colors"
>
<div className="w-10 h-10 rounded-full bg-primary-500 flex items-center justify-center">
<Phone className="w-5 h-5 text-white" />
</div>
<div>
<p className="font-medium text-primary-800">Call Clinic</p>
<p className="text-sm text-primary-600">{currentWorkspace.clinicPhone}</p>
</div>
</a>
)}
{/* Next Appointment */}
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-secondary-900">Next Appointment</h2>
<button
onClick={() => router.push('/appointments')}
className="text-sm text-primary-600 font-medium flex items-center"
>
View all
<ChevronRight className="w-4 h-4" />
</button>
</div>
{nextAppointment ? (
<Card
className="card-appointment"
onClick={() => router.push(`/appointments/${nextAppointment.id}`)}
>
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center flex-shrink-0">
<Calendar className="w-5 h-5 text-primary-600" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-secondary-900 truncate">
{nextAppointment.title}
</h3>
<p className="text-sm text-secondary-600 flex items-center gap-1 mt-1">
<Clock className="w-4 h-4" />
{formatAppointmentDate(nextAppointment.datetime)}
</p>
{nextAppointment.location && (
<p className="text-sm text-secondary-500 flex items-center gap-1 mt-0.5">
<MapPin className="w-4 h-4" />
<span className="truncate">{nextAppointment.location}</span>
</p>
)}
</div>
<ChevronRight className="w-5 h-5 text-secondary-400" />
</div>
{nextAppointment.mapUrl && (
<a
href={nextAppointment.mapUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1.5 mt-3 text-sm text-primary-600 font-medium hover:text-primary-700"
>
<MapPin className="w-4 h-4" />
Open in Maps
</a>
)}
</Card>
) : (
<Card variant="outline" className="text-center py-6">
<Calendar className="w-8 h-8 text-secondary-300 mx-auto mb-2" />
<p className="text-secondary-500">No upcoming appointments</p>
<Button
variant="ghost"
size="sm"
className="mt-2"
onClick={() => router.push('/appointments/new')}
>
<Plus className="w-4 h-4 mr-1" />
Add one
</Button>
</Card>
)}
</section>
{/* Meds Due */}
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-secondary-900">Medications</h2>
<button
onClick={() => router.push('/meds')}
className="text-sm text-primary-600 font-medium flex items-center"
>
View all
<ChevronRight className="w-4 h-4" />
</button>
</div>
{medsDueSoon.length > 0 ? (
<div className="space-y-3">
{medsDueSoon.map((status) => (
<MedicationCard
key={status.medication.id}
status={status}
now={now}
onTake={() => handleTakeMed(status)}
/>
))}
</div>
) : medications.length > 0 ? (
<Card variant="outline" className="text-center py-6">
<Pill className="w-8 h-8 text-secondary-300 mx-auto mb-2" />
<p className="text-secondary-500">All caught up! No meds due soon.</p>
</Card>
) : (
<EmptyState
type="medications"
title="No medications"
description="Add medications to track when to take them."
action={{
label: 'Add Medication',
onClick: () => router.push('/meds/new'),
}}
/>
)}
</section>
{/* Quick Note */}
<section>
<h2 className="text-lg font-semibold text-secondary-900 mb-3">Quick Note</h2>
<Card padding="sm">
<div className="flex gap-2">
<input
type="text"
value={quickNote}
onChange={(e) => setQuickNote(e.target.value)}
placeholder="Jot down a thought..."
className="flex-1 px-3 py-2.5 border border-border rounded-button text-base focus:outline-none focus:ring-2 focus:ring-primary-200 focus:border-primary-500"
onKeyDown={(e) => {
if (e.key === 'Enter' && quickNote.trim()) {
handleAddQuickNote()
}
}}
/>
<Button
onClick={handleAddQuickNote}
disabled={!quickNote.trim() || isAddingNote}
loading={isAddingNote}
>
Add
</Button>
</div>
</Card>
</section>
</PageContainer>
</>
)
}
interface MedicationCardProps {
status: MedicationDueStatus
now: Date
onTake: () => void
}
function MedicationCard({ status, now, onTake }: MedicationCardProps) {
const { medication, isOverdue, isPRN, prnAvailable, prnAvailableAt, nextDueAt } = status
const getTimeLabel = () => {
if (isOverdue && nextDueAt) {
return formatTimeUntil(nextDueAt, now)
}
if (isPRN) {
if (prnAvailable) {
return 'Available now'
}
if (prnAvailableAt) {
return `Available ${formatTimeUntil(prnAvailableAt, now)}`
}
return 'As needed'
}
if (nextDueAt) {
return formatTimeUntil(nextDueAt, now)
}
return ''
}
const canTake = !isPRN || prnAvailable
return (
<Card className={isOverdue ? 'overdue' : ''}>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center flex-shrink-0">
<Pill className="w-5 h-5 text-primary-600" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-secondary-900">{medication.name}</h3>
<p className={`text-sm ${isOverdue ? 'text-red-600 font-medium' : 'text-secondary-500'}`}>
{getTimeLabel()}
{isPRN && ' • As needed'}
</p>
</div>
<Button
onClick={(e) => {
e.stopPropagation()
onTake()
}}
variant="success"
size="md"
disabled={!canTake}
>
Taken
</Button>
</div>
{medication.instructions && (
<p className="text-sm text-secondary-500 mt-2 ml-13">
{medication.instructions}
</p>
)}
</Card>
)
}

View File

@@ -0,0 +1,96 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import {
verifyPassword,
createSession,
getSessionCookieConfig,
checkLoginRateLimit,
recordLoginAttempt,
} from '@/lib/auth'
import { loginSchema } from '@/lib/validation'
import { withRateLimit } from '@/lib/auth/middleware'
async function handler(req: NextRequest) {
try {
const body = await req.json()
const result = loginSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid input', details: result.error.flatten() },
{ status: 400 }
)
}
const { email, password } = result.data
const ipAddress = req.headers.get('x-forwarded-for')?.split(',')[0]
// Check rate limit
const rateLimit = await checkLoginRateLimit(email.toLowerCase(), ipAddress)
if (!rateLimit.allowed) {
return NextResponse.json(
{
error: `Too many failed attempts. Please try again in ${rateLimit.lockoutMinutes} minutes.`,
},
{ status: 429 }
)
}
// Find user
const user = await prisma.user.findUnique({
where: { email: email.toLowerCase() },
select: {
id: true,
email: true,
name: true,
passwordHash: true,
},
})
if (!user) {
await recordLoginAttempt(email.toLowerCase(), false, ipAddress)
return NextResponse.json(
{ error: 'Invalid email or password' },
{ status: 401 }
)
}
// Verify password
const valid = await verifyPassword(user.passwordHash, password)
if (!valid) {
await recordLoginAttempt(email.toLowerCase(), false, ipAddress)
return NextResponse.json(
{ error: 'Invalid email or password' },
{ status: 401 }
)
}
// Record successful login
await recordLoginAttempt(email.toLowerCase(), true, ipAddress)
// Create session
const userAgent = req.headers.get('user-agent') || undefined
const token = await createSession(user.id, userAgent, ipAddress)
const cookieConfig = getSessionCookieConfig(token)
const response = NextResponse.json({
user: {
id: user.id,
email: user.email,
name: user.name,
},
})
response.cookies.set(cookieConfig)
return response
} catch (error) {
console.error('Login error:', error)
return NextResponse.json(
{ error: 'Login failed' },
{ status: 500 }
)
}
}
export const POST = withRateLimit(handler)

View File

@@ -0,0 +1,25 @@
import { NextResponse } from 'next/server'
import { getSession, deleteSession, getSessionCookieClearConfig } from '@/lib/auth'
export async function POST() {
try {
const session = await getSession()
if (session) {
await deleteSession(session.sessionId)
}
const cookieConfig = getSessionCookieClearConfig()
const response = NextResponse.json({ message: 'Logged out successfully' })
response.cookies.set(cookieConfig)
return response
} catch (error) {
console.error('Logout error:', error)
// Still clear the cookie even on error
const cookieConfig = getSessionCookieClearConfig()
const response = NextResponse.json({ message: 'Logged out' })
response.cookies.set(cookieConfig)
return response
}
}

View File

@@ -0,0 +1,46 @@
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { prisma } from '@/lib/db/prisma'
export async function GET() {
try {
const session = await getSession()
if (!session) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
)
}
// Get user's workspaces
const memberships = await prisma.workspaceMember.findMany({
where: { userId: session.user.id },
include: {
workspace: {
select: {
id: true,
name: true,
clinicPhone: true,
emergencyPhone: true,
largeTextMode: true,
},
},
},
})
return NextResponse.json({
user: session.user,
workspaces: memberships.map((m) => ({
...m.workspace,
role: m.role,
})),
})
} catch (error) {
console.error('Get user error:', error)
return NextResponse.json(
{ error: 'Failed to get user info' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { hashPassword, createSession, getSessionCookieConfig } from '@/lib/auth'
import { registerSchema } from '@/lib/validation'
import { withRateLimit } from '@/lib/auth/middleware'
async function handler(req: NextRequest) {
try {
const body = await req.json()
const result = registerSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid input', details: result.error.flatten() },
{ status: 400 }
)
}
const { email, password, name } = result.data
// Check if user exists
const existing = await prisma.user.findUnique({
where: { email: email.toLowerCase() },
})
if (existing) {
return NextResponse.json(
{ error: 'An account with this email already exists' },
{ status: 409 }
)
}
// Create user
const passwordHash = await hashPassword(password)
const user = await prisma.user.create({
data: {
email: email.toLowerCase(),
passwordHash,
name,
},
select: {
id: true,
email: true,
name: true,
},
})
// Create session
const userAgent = req.headers.get('user-agent') || undefined
const ipAddress = req.headers.get('x-forwarded-for')?.split(',')[0]
const token = await createSession(user.id, userAgent, ipAddress)
const cookieConfig = getSessionCookieConfig(token)
const response = NextResponse.json({
user,
message: 'Account created successfully',
})
response.cookies.set(cookieConfig)
return response
} catch (error) {
console.error('Registration error:', error)
return NextResponse.json(
{ error: 'Registration failed' },
{ status: 500 }
)
}
}
export const POST = withRateLimit(handler)

View File

@@ -0,0 +1,25 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
export async function GET() {
try {
// Check database connectivity
await prisma.$queryRaw`SELECT 1`
return NextResponse.json({
status: 'healthy',
timestamp: new Date().toISOString(),
version: process.env.npm_package_version || '1.0.0',
})
} catch (error) {
console.error('Health check failed:', error)
return NextResponse.json(
{
status: 'unhealthy',
timestamp: new Date().toISOString(),
error: 'Database connection failed',
},
{ status: 503 }
)
}
}

View File

@@ -0,0 +1,175 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { getSession } from '@/lib/auth'
import { withRateLimit } from '@/lib/auth/middleware'
// GET /api/invite/[token] - Get invite details (public)
async function getHandler(
req: NextRequest,
{ params }: { params: Promise<{ token: string }> }
) {
try {
const { token } = await params
const invite = await prisma.inviteToken.findUnique({
where: { token },
include: {
workspace: {
select: {
id: true,
name: true,
},
},
},
})
if (!invite) {
return NextResponse.json(
{ error: 'Invite not found' },
{ status: 404 }
)
}
if (invite.usedAt) {
return NextResponse.json(
{ error: 'This invite has already been used' },
{ status: 410 }
)
}
if (invite.expiresAt < new Date()) {
return NextResponse.json(
{ error: 'This invite has expired' },
{ status: 410 }
)
}
return NextResponse.json({
invite: {
workspaceName: invite.workspace.name,
role: invite.role,
expiresAt: invite.expiresAt,
},
})
} catch (error) {
console.error('Get invite error:', error)
return NextResponse.json(
{ error: 'Failed to get invite' },
{ status: 500 }
)
}
}
// POST /api/invite/[token] - Accept invite (requires auth)
async function postHandler(
req: NextRequest,
{ params }: { params: Promise<{ token: string }> }
) {
try {
const session = await getSession()
if (!session) {
return NextResponse.json(
{ error: 'You must be logged in to accept an invite' },
{ status: 401 }
)
}
const { token } = await params
const invite = await prisma.inviteToken.findUnique({
where: { token },
include: {
workspace: {
select: {
id: true,
name: true,
},
},
},
})
if (!invite) {
return NextResponse.json(
{ error: 'Invite not found' },
{ status: 404 }
)
}
if (invite.usedAt) {
return NextResponse.json(
{ error: 'This invite has already been used' },
{ status: 410 }
)
}
if (invite.expiresAt < new Date()) {
return NextResponse.json(
{ error: 'This invite has expired' },
{ status: 410 }
)
}
// Check if already a member
const existingMember = await prisma.workspaceMember.findUnique({
where: {
workspaceId_userId: {
workspaceId: invite.workspaceId,
userId: session.user.id,
},
},
})
if (existingMember) {
return NextResponse.json(
{ error: 'You are already a member of this workspace' },
{ status: 409 }
)
}
// Accept invite in a transaction
const [member] = await prisma.$transaction([
prisma.workspaceMember.create({
data: {
workspaceId: invite.workspaceId,
userId: session.user.id,
role: invite.role,
},
}),
prisma.inviteToken.update({
where: { id: invite.id },
data: {
usedAt: new Date(),
usedById: session.user.id,
},
}),
prisma.auditLog.create({
data: {
workspaceId: invite.workspaceId,
userId: session.user.id,
action: 'JOIN',
entityType: 'WORKSPACE',
entityId: invite.workspaceId,
details: { role: invite.role, inviteToken: token },
},
}),
])
return NextResponse.json({
workspace: {
id: invite.workspace.id,
name: invite.workspace.name,
role: member.role,
},
})
} catch (error) {
console.error('Accept invite error:', error)
return NextResponse.json(
{ error: 'Failed to accept invite' },
{ status: 500 }
)
}
}
export const GET = withRateLimit(getHandler)
export const POST = withRateLimit(postHandler)

315
src/app/api/sync/route.ts Normal file
View File

@@ -0,0 +1,315 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { syncQuerySchema, syncOpsSchema } from '@/lib/validation'
// GET /api/sync - Get changes since cursor
export const GET = withAuth(async (req: AuthenticatedRequest) => {
try {
const { searchParams } = new URL(req.url)
const result = syncQuerySchema.safeParse({
workspaceId: searchParams.get('workspaceId'),
since: searchParams.get('since'),
})
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid input', details: result.error.flatten() },
{ status: 400 }
)
}
const { workspaceId, since = 0 } = result.data
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) {
return NextResponse.json(
{ error: 'Access denied' },
{ status: 403 }
)
}
const sinceDate = new Date(since)
// Fetch all changed entities
const [appointments, medications, notes, doseLogs, workspace] = await Promise.all([
prisma.appointment.findMany({
where: { workspaceId, syncedAt: { gt: sinceDate } },
include: {
createdBy: { select: { id: true, name: true } },
updatedBy: { select: { id: true, name: true } },
},
}),
prisma.medication.findMany({
where: { workspaceId, syncedAt: { gt: sinceDate } },
include: {
createdBy: { select: { id: true, name: true } },
updatedBy: { select: { id: true, name: true } },
},
}),
prisma.note.findMany({
where: { workspaceId, syncedAt: { gt: sinceDate } },
include: {
createdBy: { select: { id: true, name: true } },
updatedBy: { select: { id: true, name: true } },
},
}),
prisma.doseLog.findMany({
where: { workspaceId, syncedAt: { gt: sinceDate } },
include: {
medication: { select: { id: true, name: true } },
loggedBy: { select: { id: true, name: true } },
undoneBy: { select: { id: true, name: true } },
},
}),
prisma.workspace.findUnique({
where: { id: workspaceId },
select: {
id: true,
name: true,
clinicPhone: true,
emergencyPhone: true,
quietHoursStart: true,
quietHoursEnd: true,
largeTextMode: true,
updatedAt: true,
},
}),
])
// Calculate new cursor (latest syncedAt timestamp)
let cursor = since
const allItems = [...appointments, ...medications, ...notes, ...doseLogs]
for (const item of allItems) {
const itemTime = (item as { syncedAt: Date }).syncedAt.getTime()
if (itemTime > cursor) {
cursor = itemTime
}
}
return NextResponse.json({
workspace,
appointments,
medications,
notes,
doseLogs,
cursor,
hasConflicts: false, // For now, always false - client handles conflicts
})
} catch (error) {
console.error('Sync get error:', error)
return NextResponse.json(
{ error: 'Sync failed' },
{ status: 500 }
)
}
})
// POST /api/sync - Upload operations from client outbox
export const POST = withAuth(async (req: AuthenticatedRequest) => {
try {
const body = await req.json()
const result = syncOpsSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid input', details: result.error.flatten() },
{ status: 400 }
)
}
const { workspaceId, ops } = result.data
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id, ['OWNER', 'EDITOR'])
if (!access) {
return NextResponse.json(
{ error: 'Access denied' },
{ status: 403 }
)
}
const results: { opId: string; success: boolean; entityId?: string; error?: string }[] = []
for (const op of ops) {
try {
switch (op.type) {
case 'CREATE': {
if (op.entityType === 'APPOINTMENT' && op.data) {
const appt = await prisma.appointment.create({
data: {
workspaceId,
title: op.data.title as string,
datetime: new Date(op.data.datetime as string),
location: (op.data.location as string) || null,
mapUrl: (op.data.mapUrl as string) || null,
notes: (op.data.notes as string) || null,
createdById: req.session.user.id,
updatedById: req.session.user.id,
},
})
results.push({ opId: op.id, success: true, entityId: appt.id })
} else if (op.entityType === 'NOTE' && op.data) {
const note = await prisma.note.create({
data: {
workspaceId,
type: op.data.type as 'QUESTION' | 'GENERAL',
content: op.data.content as string,
createdById: req.session.user.id,
updatedById: req.session.user.id,
},
})
results.push({ opId: op.id, success: true, entityId: note.id })
} else {
results.push({ opId: op.id, success: false, error: 'Unsupported entity type' })
}
break
}
case 'UPDATE': {
if (!op.entityId) {
results.push({ opId: op.id, success: false, error: 'Missing entityId' })
break
}
if (op.entityType === 'APPOINTMENT' && op.data) {
await prisma.appointment.update({
where: { id: op.entityId },
data: {
...(op.data.title && { title: op.data.title as string }),
...(op.data.datetime && { datetime: new Date(op.data.datetime as string) }),
...(op.data.location !== undefined && { location: op.data.location as string | null }),
...(op.data.mapUrl !== undefined && { mapUrl: op.data.mapUrl as string | null }),
...(op.data.notes !== undefined && { notes: op.data.notes as string | null }),
updatedById: req.session.user.id,
version: { increment: 1 },
syncedAt: new Date(),
},
})
results.push({ opId: op.id, success: true, entityId: op.entityId })
} else if (op.entityType === 'NOTE' && op.data) {
await prisma.note.update({
where: { id: op.entityId },
data: {
...(op.data.content && { content: op.data.content as string }),
updatedById: req.session.user.id,
version: { increment: 1 },
syncedAt: new Date(),
},
})
results.push({ opId: op.id, success: true, entityId: op.entityId })
} else {
results.push({ opId: op.id, success: false, error: 'Unsupported entity type' })
}
break
}
case 'DELETE': {
if (!op.entityId) {
results.push({ opId: op.id, success: false, error: 'Missing entityId' })
break
}
if (op.entityType === 'APPOINTMENT') {
await prisma.appointment.update({
where: { id: op.entityId },
data: {
deletedAt: new Date(),
updatedById: req.session.user.id,
version: { increment: 1 },
syncedAt: new Date(),
},
})
results.push({ opId: op.id, success: true })
} else if (op.entityType === 'NOTE') {
await prisma.note.update({
where: { id: op.entityId },
data: {
deletedAt: new Date(),
updatedById: req.session.user.id,
version: { increment: 1 },
syncedAt: new Date(),
},
})
results.push({ opId: op.id, success: true })
} else {
results.push({ opId: op.id, success: false, error: 'Unsupported entity type' })
}
break
}
case 'TAKE_DOSE': {
if (!op.data?.medicationId) {
results.push({ opId: op.id, success: false, error: 'Missing medicationId' })
break
}
const doseLog = await prisma.doseLog.create({
data: {
workspaceId,
medicationId: op.data.medicationId as string,
takenAt: op.data.takenAt ? new Date(op.data.takenAt as string) : new Date(),
loggedById: req.session.user.id,
},
})
results.push({ opId: op.id, success: true, entityId: doseLog.id })
break
}
case 'UNDO_DOSE': {
if (!op.entityId) {
results.push({ opId: op.id, success: false, error: 'Missing entityId' })
break
}
await prisma.doseLog.update({
where: { id: op.entityId },
data: {
undoneAt: new Date(),
undoneById: req.session.user.id,
},
})
results.push({ opId: op.id, success: true })
break
}
case 'MARK_ASKED': {
if (!op.entityId) {
results.push({ opId: op.id, success: false, error: 'Missing entityId' })
break
}
await prisma.note.update({
where: { id: op.entityId },
data: {
askedAt: new Date(),
updatedById: req.session.user.id,
version: { increment: 1 },
syncedAt: new Date(),
},
})
results.push({ opId: op.id, success: true })
break
}
default:
results.push({ opId: op.id, success: false, error: 'Unknown operation type' })
}
} catch (opError) {
console.error('Op error:', opError)
results.push({ opId: op.id, success: false, error: 'Operation failed' })
}
}
// Get new cursor
const cursor = Date.now()
return NextResponse.json({ results, cursor })
} catch (error) {
console.error('Sync post error:', error)
return NextResponse.json(
{ error: 'Sync failed' },
{ status: 500 }
)
}
})

View File

@@ -0,0 +1,175 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { appointmentSchema } from '@/lib/validation'
// GET /api/workspaces/[id]/appointments/[appointmentId]
export const GET = withAuth(async (req: AuthenticatedRequest, { params }) => {
try {
const { id: workspaceId, appointmentId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) {
return NextResponse.json(
{ error: 'Access denied' },
{ status: 403 }
)
}
const appointment = await prisma.appointment.findFirst({
where: { id: appointmentId, workspaceId },
include: {
createdBy: { select: { id: true, name: true } },
updatedBy: { select: { id: true, name: true } },
},
})
if (!appointment) {
return NextResponse.json(
{ error: 'Appointment not found' },
{ status: 404 }
)
}
return NextResponse.json({ appointment })
} catch (error) {
console.error('Get appointment error:', error)
return NextResponse.json(
{ error: 'Failed to get appointment' },
{ status: 500 }
)
}
})
// PATCH /api/workspaces/[id]/appointments/[appointmentId]
export const PATCH = withAuth(async (req: AuthenticatedRequest, { params }) => {
try {
const { id: workspaceId, appointmentId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access || !canEdit(access.role)) {
return NextResponse.json(
{ error: 'Access denied' },
{ status: 403 }
)
}
const existing = await prisma.appointment.findFirst({
where: { id: appointmentId, workspaceId, deletedAt: null },
})
if (!existing) {
return NextResponse.json(
{ error: 'Appointment not found' },
{ status: 404 }
)
}
const body = await req.json()
const result = appointmentSchema.partial().safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid input', details: result.error.flatten() },
{ status: 400 }
)
}
const updateData: Record<string, unknown> = {
...result.data,
updatedById: req.session.user.id,
version: { increment: 1 },
syncedAt: new Date(),
}
if (result.data.datetime) {
updateData.datetime = new Date(result.data.datetime)
}
const appointment = await prisma.appointment.update({
where: { id: appointmentId },
data: updateData,
include: {
createdBy: { select: { id: true, name: true } },
updatedBy: { select: { id: true, name: true } },
},
})
// Audit log
await prisma.auditLog.create({
data: {
workspaceId,
userId: req.session.user.id,
action: 'UPDATE',
entityType: 'APPOINTMENT',
entityId: appointmentId,
details: result.data,
},
})
return NextResponse.json({ appointment })
} catch (error) {
console.error('Update appointment error:', error)
return NextResponse.json(
{ error: 'Failed to update appointment' },
{ status: 500 }
)
}
})
// DELETE /api/workspaces/[id]/appointments/[appointmentId] (soft delete)
export const DELETE = withAuth(async (req: AuthenticatedRequest, { params }) => {
try {
const { id: workspaceId, appointmentId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access || !canEdit(access.role)) {
return NextResponse.json(
{ error: 'Access denied' },
{ status: 403 }
)
}
const existing = await prisma.appointment.findFirst({
where: { id: appointmentId, workspaceId, deletedAt: null },
})
if (!existing) {
return NextResponse.json(
{ error: 'Appointment not found' },
{ status: 404 }
)
}
await prisma.appointment.update({
where: { id: appointmentId },
data: {
deletedAt: new Date(),
updatedById: req.session.user.id,
version: { increment: 1 },
syncedAt: new Date(),
},
})
// Audit log
await prisma.auditLog.create({
data: {
workspaceId,
userId: req.session.user.id,
action: 'DELETE',
entityType: 'APPOINTMENT',
entityId: appointmentId,
details: { title: existing.title },
},
})
return NextResponse.json({ message: 'Appointment deleted' })
} catch (error) {
console.error('Delete appointment error:', error)
return NextResponse.json(
{ error: 'Failed to delete appointment' },
{ status: 500 }
)
}
})

View File

@@ -0,0 +1,116 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { appointmentSchema } from '@/lib/validation'
// GET /api/workspaces/[id]/appointments - List appointments
export const GET = withAuth(async (req: AuthenticatedRequest, { params }) => {
try {
const { id: workspaceId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) {
return NextResponse.json(
{ error: 'Workspace not found or access denied' },
{ status: 404 }
)
}
const { searchParams } = new URL(req.url)
const includeDeleted = searchParams.get('includeDeleted') === 'true'
const fromDate = searchParams.get('from')
const toDate = searchParams.get('to')
const where: Record<string, unknown> = {
workspaceId,
...(includeDeleted ? {} : { deletedAt: null }),
}
if (fromDate) {
where.datetime = { ...(where.datetime as object || {}), gte: new Date(fromDate) }
}
if (toDate) {
where.datetime = { ...(where.datetime as object || {}), lte: new Date(toDate) }
}
const appointments = await prisma.appointment.findMany({
where,
orderBy: { datetime: 'asc' },
include: {
createdBy: { select: { id: true, name: true } },
updatedBy: { select: { id: true, name: true } },
},
})
return NextResponse.json({ appointments })
} catch (error) {
console.error('List appointments error:', error)
return NextResponse.json(
{ error: 'Failed to list appointments' },
{ status: 500 }
)
}
})
// POST /api/workspaces/[id]/appointments - Create appointment
export const POST = withAuth(async (req: AuthenticatedRequest, { params }) => {
try {
const { id: workspaceId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access || !canEdit(access.role)) {
return NextResponse.json(
{ error: 'Access denied' },
{ status: 403 }
)
}
const body = await req.json()
const result = appointmentSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid input', details: result.error.flatten() },
{ status: 400 }
)
}
const appointment = await prisma.appointment.create({
data: {
workspaceId,
title: result.data.title,
datetime: new Date(result.data.datetime),
location: result.data.location || null,
mapUrl: result.data.mapUrl || null,
notes: result.data.notes || null,
createdById: req.session.user.id,
updatedById: req.session.user.id,
},
include: {
createdBy: { select: { id: true, name: true } },
updatedBy: { select: { id: true, name: true } },
},
})
// Audit log
await prisma.auditLog.create({
data: {
workspaceId,
userId: req.session.user.id,
action: 'CREATE',
entityType: 'APPOINTMENT',
entityId: appointment.id,
details: { title: appointment.title },
},
})
return NextResponse.json({ appointment }, { status: 201 })
} catch (error) {
console.error('Create appointment error:', error)
return NextResponse.json(
{ error: 'Failed to create appointment' },
{ status: 500 }
)
}
})

View File

@@ -0,0 +1,221 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { doseLogSchema, undoDoseSchema } from '@/lib/validation'
// GET /api/workspaces/[id]/doses - List dose logs
export const GET = withAuth(async (req: AuthenticatedRequest, { params }) => {
try {
const { id: workspaceId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) {
return NextResponse.json(
{ error: 'Access denied' },
{ status: 403 }
)
}
const { searchParams } = new URL(req.url)
const medicationId = searchParams.get('medicationId')
const fromDate = searchParams.get('from')
const toDate = searchParams.get('to')
const includeUndone = searchParams.get('includeUndone') === 'true'
const limit = Math.min(parseInt(searchParams.get('limit') || '100'), 500)
const where: Record<string, unknown> = {
workspaceId,
...(medicationId ? { medicationId } : {}),
...(includeUndone ? {} : { undoneAt: null }),
}
if (fromDate || toDate) {
where.takenAt = {}
if (fromDate) (where.takenAt as Record<string, unknown>).gte = new Date(fromDate)
if (toDate) (where.takenAt as Record<string, unknown>).lte = new Date(toDate)
}
const doseLogs = await prisma.doseLog.findMany({
where,
orderBy: { takenAt: 'desc' },
take: limit,
include: {
medication: {
select: { id: true, name: true },
},
loggedBy: { select: { id: true, name: true } },
undoneBy: { select: { id: true, name: true } },
},
})
return NextResponse.json({ doseLogs })
} catch (error) {
console.error('List dose logs error:', error)
return NextResponse.json(
{ error: 'Failed to list dose logs' },
{ status: 500 }
)
}
})
// POST /api/workspaces/[id]/doses - Log a dose (Take medication)
export const POST = withAuth(async (req: AuthenticatedRequest, { params }) => {
try {
const { id: workspaceId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access || !canEdit(access.role)) {
return NextResponse.json(
{ error: 'Access denied' },
{ status: 403 }
)
}
const body = await req.json()
const result = doseLogSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid input', details: result.error.flatten() },
{ status: 400 }
)
}
// Verify medication exists and belongs to workspace
const medication = await prisma.medication.findFirst({
where: {
id: result.data.medicationId,
workspaceId,
deletedAt: null,
},
})
if (!medication) {
return NextResponse.json(
{ error: 'Medication not found' },
{ status: 404 }
)
}
const takenAt = result.data.takenAt ? new Date(result.data.takenAt) : new Date()
const doseLog = await prisma.doseLog.create({
data: {
medicationId: result.data.medicationId,
workspaceId,
takenAt,
loggedById: req.session.user.id,
},
include: {
medication: { select: { id: true, name: true } },
loggedBy: { select: { id: true, name: true } },
},
})
// Audit log
await prisma.auditLog.create({
data: {
workspaceId,
userId: req.session.user.id,
action: 'TAKE_DOSE',
entityType: 'DOSE_LOG',
entityId: doseLog.id,
details: { medicationName: medication.name, takenAt: takenAt.toISOString() },
},
})
return NextResponse.json({ doseLog }, { status: 201 })
} catch (error) {
console.error('Log dose error:', error)
return NextResponse.json(
{ error: 'Failed to log dose' },
{ status: 500 }
)
}
})
// PATCH /api/workspaces/[id]/doses - Undo a dose
export const PATCH = withAuth(async (req: AuthenticatedRequest, { params }) => {
try {
const { id: workspaceId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access || !canEdit(access.role)) {
return NextResponse.json(
{ error: 'Access denied' },
{ status: 403 }
)
}
const body = await req.json()
const result = undoDoseSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid input', details: result.error.flatten() },
{ status: 400 }
)
}
const doseLog = await prisma.doseLog.findFirst({
where: {
id: result.data.doseLogId,
workspaceId,
undoneAt: null,
},
include: {
medication: { select: { name: true } },
},
})
if (!doseLog) {
return NextResponse.json(
{ error: 'Dose log not found or already undone' },
{ status: 404 }
)
}
// Check if within undo window (5 minutes)
const minutesSinceDose = (Date.now() - doseLog.takenAt.getTime()) / 1000 / 60
if (minutesSinceDose > 5) {
return NextResponse.json(
{ error: 'Undo window has expired (5 minutes)' },
{ status: 400 }
)
}
const updated = await prisma.doseLog.update({
where: { id: doseLog.id },
data: {
undoneAt: new Date(),
undoneById: req.session.user.id,
},
include: {
medication: { select: { id: true, name: true } },
loggedBy: { select: { id: true, name: true } },
undoneBy: { select: { id: true, name: true } },
},
})
// Audit log
await prisma.auditLog.create({
data: {
workspaceId,
userId: req.session.user.id,
action: 'UNDO_DOSE',
entityType: 'DOSE_LOG',
entityId: doseLog.id,
details: { medicationName: doseLog.medication.name },
},
})
return NextResponse.json({ doseLog: updated })
} catch (error) {
console.error('Undo dose error:', error)
return NextResponse.json(
{ error: 'Failed to undo dose' },
{ status: 500 }
)
}
})

View File

@@ -0,0 +1,126 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { inviteSchema } from '@/lib/validation'
import { nanoid } from 'nanoid'
// Helper to check workspace access
async function checkWorkspaceAccess(
workspaceId: string,
userId: string,
requiredRoles: string[] = ['OWNER']
) {
const member = await prisma.workspaceMember.findUnique({
where: {
workspaceId_userId: { workspaceId, userId },
},
})
if (!member || !requiredRoles.includes(member.role)) {
return null
}
return member
}
// POST /api/workspaces/[id]/invite - Create invite token
export const POST = withAuth(async (req: AuthenticatedRequest, { params }) => {
try {
const { id } = await params
// Only owners can create invites
const member = await checkWorkspaceAccess(id, req.session.user.id, ['OWNER'])
if (!member) {
return NextResponse.json(
{ error: 'Only workspace owners can create invites' },
{ status: 403 }
)
}
const body = await req.json()
const result = inviteSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid input', details: result.error.flatten() },
{ status: 400 }
)
}
const { role, expiresInDays } = result.data
const token = nanoid(32)
const expiresAt = new Date()
expiresAt.setDate(expiresAt.getDate() + expiresInDays)
const invite = await prisma.inviteToken.create({
data: {
workspaceId: id,
token,
role,
expiresAt,
},
})
// Build invite URL
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
const inviteUrl = `${baseUrl}/invite/${token}`
return NextResponse.json({
invite: {
token: invite.token,
role: invite.role,
expiresAt: invite.expiresAt,
url: inviteUrl,
},
})
} catch (error) {
console.error('Create invite error:', error)
return NextResponse.json(
{ error: 'Failed to create invite' },
{ status: 500 }
)
}
})
// GET /api/workspaces/[id]/invite - List active invites
export const GET = withAuth(async (req: AuthenticatedRequest, { params }) => {
try {
const { id } = await params
const member = await checkWorkspaceAccess(id, req.session.user.id, ['OWNER'])
if (!member) {
return NextResponse.json(
{ error: 'Only workspace owners can view invites' },
{ status: 403 }
)
}
const invites = await prisma.inviteToken.findMany({
where: {
workspaceId: id,
usedAt: null,
expiresAt: { gt: new Date() },
},
orderBy: { createdAt: 'desc' },
})
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
return NextResponse.json({
invites: invites.map((i) => ({
id: i.id,
token: i.token,
role: i.role,
expiresAt: i.expiresAt,
url: `${baseUrl}/invite/${i.token}`,
})),
})
} catch (error) {
console.error('List invites error:', error)
return NextResponse.json(
{ error: 'Failed to list invites' },
{ status: 500 }
)
}
})

View File

@@ -0,0 +1,187 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { medicationSchema } from '@/lib/validation'
// GET /api/workspaces/[id]/medications/[medicationId]
export const GET = withAuth(async (req: AuthenticatedRequest, { params }) => {
try {
const { id: workspaceId, medicationId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) {
return NextResponse.json(
{ error: 'Access denied' },
{ status: 403 }
)
}
const medication = await prisma.medication.findFirst({
where: { id: medicationId, workspaceId },
include: {
createdBy: { select: { id: true, name: true } },
updatedBy: { select: { id: true, name: true } },
doseLogs: {
where: { undoneAt: null },
orderBy: { takenAt: 'desc' },
take: 10,
include: {
loggedBy: { select: { id: true, name: true } },
},
},
},
})
if (!medication) {
return NextResponse.json(
{ error: 'Medication not found' },
{ status: 404 }
)
}
return NextResponse.json({ medication })
} catch (error) {
console.error('Get medication error:', error)
return NextResponse.json(
{ error: 'Failed to get medication' },
{ status: 500 }
)
}
})
// PATCH /api/workspaces/[id]/medications/[medicationId]
export const PATCH = withAuth(async (req: AuthenticatedRequest, { params }) => {
try {
const { id: workspaceId, medicationId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access || !canEdit(access.role)) {
return NextResponse.json(
{ error: 'Access denied' },
{ status: 403 }
)
}
const existing = await prisma.medication.findFirst({
where: { id: medicationId, workspaceId, deletedAt: null },
})
if (!existing) {
return NextResponse.json(
{ error: 'Medication not found' },
{ status: 404 }
)
}
const body = await req.json()
const result = medicationSchema.partial().safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid input', details: result.error.flatten() },
{ status: 400 }
)
}
const updateData: Record<string, unknown> = {
...result.data,
updatedById: req.session.user.id,
version: { increment: 1 },
syncedAt: new Date(),
}
if (result.data.startDate !== undefined) {
updateData.startDate = result.data.startDate ? new Date(result.data.startDate) : null
}
if (result.data.endDate !== undefined) {
updateData.endDate = result.data.endDate ? new Date(result.data.endDate) : null
}
const medication = await prisma.medication.update({
where: { id: medicationId },
data: updateData,
include: {
createdBy: { select: { id: true, name: true } },
updatedBy: { select: { id: true, name: true } },
},
})
// Audit log
await prisma.auditLog.create({
data: {
workspaceId,
userId: req.session.user.id,
action: 'UPDATE',
entityType: 'MEDICATION',
entityId: medicationId,
details: result.data,
},
})
return NextResponse.json({ medication })
} catch (error) {
console.error('Update medication error:', error)
return NextResponse.json(
{ error: 'Failed to update medication' },
{ status: 500 }
)
}
})
// DELETE /api/workspaces/[id]/medications/[medicationId] (soft delete)
export const DELETE = withAuth(async (req: AuthenticatedRequest, { params }) => {
try {
const { id: workspaceId, medicationId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access || !canEdit(access.role)) {
return NextResponse.json(
{ error: 'Access denied' },
{ status: 403 }
)
}
const existing = await prisma.medication.findFirst({
where: { id: medicationId, workspaceId, deletedAt: null },
})
if (!existing) {
return NextResponse.json(
{ error: 'Medication not found' },
{ status: 404 }
)
}
await prisma.medication.update({
where: { id: medicationId },
data: {
deletedAt: new Date(),
active: false,
updatedById: req.session.user.id,
version: { increment: 1 },
syncedAt: new Date(),
},
})
// Audit log
await prisma.auditLog.create({
data: {
workspaceId,
userId: req.session.user.id,
action: 'DELETE',
entityType: 'MEDICATION',
entityId: medicationId,
details: { name: existing.name },
},
})
return NextResponse.json({ message: 'Medication deleted' })
} catch (error) {
console.error('Delete medication error:', error)
return NextResponse.json(
{ error: 'Failed to delete medication' },
{ status: 500 }
)
}
})

View File

@@ -0,0 +1,111 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { medicationSchema } from '@/lib/validation'
// GET /api/workspaces/[id]/medications - List medications
export const GET = withAuth(async (req: AuthenticatedRequest, { params }) => {
try {
const { id: workspaceId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) {
return NextResponse.json(
{ error: 'Workspace not found or access denied' },
{ status: 404 }
)
}
const { searchParams } = new URL(req.url)
const activeOnly = searchParams.get('activeOnly') !== 'false'
const includeDeleted = searchParams.get('includeDeleted') === 'true'
const where: Record<string, unknown> = {
workspaceId,
...(includeDeleted ? {} : { deletedAt: null }),
...(activeOnly ? { active: true } : {}),
}
const medications = await prisma.medication.findMany({
where,
orderBy: { name: 'asc' },
include: {
createdBy: { select: { id: true, name: true } },
updatedBy: { select: { id: true, name: true } },
},
})
return NextResponse.json({ medications })
} catch (error) {
console.error('List medications error:', error)
return NextResponse.json(
{ error: 'Failed to list medications' },
{ status: 500 }
)
}
})
// POST /api/workspaces/[id]/medications - Create medication
export const POST = withAuth(async (req: AuthenticatedRequest, { params }) => {
try {
const { id: workspaceId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access || !canEdit(access.role)) {
return NextResponse.json(
{ error: 'Access denied' },
{ status: 403 }
)
}
const body = await req.json()
const result = medicationSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid input', details: result.error.flatten() },
{ status: 400 }
)
}
const medication = await prisma.medication.create({
data: {
workspaceId,
name: result.data.name,
instructions: result.data.instructions || null,
scheduleType: result.data.scheduleType,
scheduleData: result.data.scheduleData,
startDate: result.data.startDate ? new Date(result.data.startDate) : null,
endDate: result.data.endDate ? new Date(result.data.endDate) : null,
active: result.data.active ?? true,
createdById: req.session.user.id,
updatedById: req.session.user.id,
},
include: {
createdBy: { select: { id: true, name: true } },
updatedBy: { select: { id: true, name: true } },
},
})
// Audit log
await prisma.auditLog.create({
data: {
workspaceId,
userId: req.session.user.id,
action: 'CREATE',
entityType: 'MEDICATION',
entityId: medication.id,
details: { name: medication.name },
},
})
return NextResponse.json({ medication }, { status: 201 })
} catch (error) {
console.error('Create medication error:', error)
return NextResponse.json(
{ error: 'Failed to create medication' },
{ status: 500 }
)
}
})

View File

@@ -0,0 +1,196 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { noteSchema } from '@/lib/validation'
// GET /api/workspaces/[id]/notes/[noteId]
export const GET = withAuth(async (req: AuthenticatedRequest, { params }) => {
try {
const { id: workspaceId, noteId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) {
return NextResponse.json(
{ error: 'Access denied' },
{ status: 403 }
)
}
const note = await prisma.note.findFirst({
where: { id: noteId, workspaceId },
include: {
createdBy: { select: { id: true, name: true } },
updatedBy: { select: { id: true, name: true } },
},
})
if (!note) {
return NextResponse.json(
{ error: 'Note not found' },
{ status: 404 }
)
}
return NextResponse.json({ note })
} catch (error) {
console.error('Get note error:', error)
return NextResponse.json(
{ error: 'Failed to get note' },
{ status: 500 }
)
}
})
// PATCH /api/workspaces/[id]/notes/[noteId]
export const PATCH = withAuth(async (req: AuthenticatedRequest, { params }) => {
try {
const { id: workspaceId, noteId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access || !canEdit(access.role)) {
return NextResponse.json(
{ error: 'Access denied' },
{ status: 403 }
)
}
const existing = await prisma.note.findFirst({
where: { id: noteId, workspaceId, deletedAt: null },
})
if (!existing) {
return NextResponse.json(
{ error: 'Note not found' },
{ status: 404 }
)
}
const body = await req.json()
// Handle marking question as asked
if (body.markAsked === true && existing.type === 'QUESTION') {
const note = await prisma.note.update({
where: { id: noteId },
data: {
askedAt: new Date(),
updatedById: req.session.user.id,
version: { increment: 1 },
syncedAt: new Date(),
},
include: {
createdBy: { select: { id: true, name: true } },
updatedBy: { select: { id: true, name: true } },
},
})
await prisma.auditLog.create({
data: {
workspaceId,
userId: req.session.user.id,
action: 'MARK_ASKED',
entityType: 'NOTE',
entityId: noteId,
},
})
return NextResponse.json({ note })
}
const result = noteSchema.partial().safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid input', details: result.error.flatten() },
{ status: 400 }
)
}
const note = await prisma.note.update({
where: { id: noteId },
data: {
...result.data,
updatedById: req.session.user.id,
version: { increment: 1 },
syncedAt: new Date(),
},
include: {
createdBy: { select: { id: true, name: true } },
updatedBy: { select: { id: true, name: true } },
},
})
await prisma.auditLog.create({
data: {
workspaceId,
userId: req.session.user.id,
action: 'UPDATE',
entityType: 'NOTE',
entityId: noteId,
details: result.data,
},
})
return NextResponse.json({ note })
} catch (error) {
console.error('Update note error:', error)
return NextResponse.json(
{ error: 'Failed to update note' },
{ status: 500 }
)
}
})
// DELETE /api/workspaces/[id]/notes/[noteId] (soft delete)
export const DELETE = withAuth(async (req: AuthenticatedRequest, { params }) => {
try {
const { id: workspaceId, noteId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access || !canEdit(access.role)) {
return NextResponse.json(
{ error: 'Access denied' },
{ status: 403 }
)
}
const existing = await prisma.note.findFirst({
where: { id: noteId, workspaceId, deletedAt: null },
})
if (!existing) {
return NextResponse.json(
{ error: 'Note not found' },
{ status: 404 }
)
}
await prisma.note.update({
where: { id: noteId },
data: {
deletedAt: new Date(),
updatedById: req.session.user.id,
version: { increment: 1 },
syncedAt: new Date(),
},
})
await prisma.auditLog.create({
data: {
workspaceId,
userId: req.session.user.id,
action: 'DELETE',
entityType: 'NOTE',
entityId: noteId,
},
})
return NextResponse.json({ message: 'Note deleted' })
} catch (error) {
console.error('Delete note error:', error)
return NextResponse.json(
{ error: 'Failed to delete note' },
{ status: 500 }
)
}
})

View File

@@ -0,0 +1,106 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { noteSchema } from '@/lib/validation'
// GET /api/workspaces/[id]/notes - List notes
export const GET = withAuth(async (req: AuthenticatedRequest, { params }) => {
try {
const { id: workspaceId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access) {
return NextResponse.json(
{ error: 'Access denied' },
{ status: 403 }
)
}
const { searchParams } = new URL(req.url)
const type = searchParams.get('type') as 'QUESTION' | 'GENERAL' | null
const includeDeleted = searchParams.get('includeDeleted') === 'true'
const where: Record<string, unknown> = {
workspaceId,
...(type ? { type } : {}),
...(includeDeleted ? {} : { deletedAt: null }),
}
const notes = await prisma.note.findMany({
where,
orderBy: { createdAt: 'desc' },
include: {
createdBy: { select: { id: true, name: true } },
updatedBy: { select: { id: true, name: true } },
},
})
return NextResponse.json({ notes })
} catch (error) {
console.error('List notes error:', error)
return NextResponse.json(
{ error: 'Failed to list notes' },
{ status: 500 }
)
}
})
// POST /api/workspaces/[id]/notes - Create note
export const POST = withAuth(async (req: AuthenticatedRequest, { params }) => {
try {
const { id: workspaceId } = await params
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
if (!access || !canEdit(access.role)) {
return NextResponse.json(
{ error: 'Access denied' },
{ status: 403 }
)
}
const body = await req.json()
const result = noteSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid input', details: result.error.flatten() },
{ status: 400 }
)
}
const note = await prisma.note.create({
data: {
workspaceId,
type: result.data.type,
content: result.data.content,
createdById: req.session.user.id,
updatedById: req.session.user.id,
},
include: {
createdBy: { select: { id: true, name: true } },
updatedBy: { select: { id: true, name: true } },
},
})
// Audit log
await prisma.auditLog.create({
data: {
workspaceId,
userId: req.session.user.id,
action: 'CREATE',
entityType: 'NOTE',
entityId: note.id,
details: { type: note.type },
},
})
return NextResponse.json({ note }, { status: 201 })
} catch (error) {
console.error('Create note error:', error)
return NextResponse.json(
{ error: 'Failed to create note' },
{ status: 500 }
)
}
})

View File

@@ -0,0 +1,145 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { updateWorkspaceSchema } from '@/lib/validation'
// Helper to check workspace access
async function checkWorkspaceAccess(
workspaceId: string,
userId: string,
requiredRoles: string[] = ['OWNER', 'EDITOR', 'VIEWER']
) {
const member = await prisma.workspaceMember.findUnique({
where: {
workspaceId_userId: { workspaceId, userId },
},
})
if (!member || !requiredRoles.includes(member.role)) {
return null
}
return member
}
// GET /api/workspaces/[id] - Get workspace details
export const GET = withAuth(async (req: AuthenticatedRequest, { params }) => {
try {
const { id } = await params
const member = await checkWorkspaceAccess(id, req.session.user.id)
if (!member) {
return NextResponse.json(
{ error: 'Workspace not found or access denied' },
{ status: 404 }
)
}
const workspace = await prisma.workspace.findUnique({
where: { id },
include: {
members: {
include: {
user: {
select: {
id: true,
email: true,
name: true,
},
},
},
},
},
})
if (!workspace) {
return NextResponse.json(
{ error: 'Workspace not found' },
{ status: 404 }
)
}
return NextResponse.json({
workspace: {
id: workspace.id,
name: workspace.name,
clinicPhone: workspace.clinicPhone,
emergencyPhone: workspace.emergencyPhone,
quietHoursStart: workspace.quietHoursStart,
quietHoursEnd: workspace.quietHoursEnd,
largeTextMode: workspace.largeTextMode,
role: member.role,
members: workspace.members.map((m) => ({
id: m.id,
role: m.role,
user: m.user,
})),
},
})
} catch (error) {
console.error('Get workspace error:', error)
return NextResponse.json(
{ error: 'Failed to get workspace' },
{ status: 500 }
)
}
})
// PATCH /api/workspaces/[id] - Update workspace settings
export const PATCH = withAuth(async (req: AuthenticatedRequest, { params }) => {
try {
const { id } = await params
const member = await checkWorkspaceAccess(id, req.session.user.id, ['OWNER', 'EDITOR'])
if (!member) {
return NextResponse.json(
{ error: 'Workspace not found or access denied' },
{ status: 404 }
)
}
const body = await req.json()
const result = updateWorkspaceSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid input', details: result.error.flatten() },
{ status: 400 }
)
}
const workspace = await prisma.workspace.update({
where: { id },
data: result.data,
select: {
id: true,
name: true,
clinicPhone: true,
emergencyPhone: true,
quietHoursStart: true,
quietHoursEnd: true,
largeTextMode: true,
},
})
// Audit log
await prisma.auditLog.create({
data: {
workspaceId: id,
userId: req.session.user.id,
action: 'UPDATE',
entityType: 'WORKSPACE',
entityId: id,
details: result.data,
},
})
return NextResponse.json({ workspace })
} catch (error) {
console.error('Update workspace error:', error)
return NextResponse.json(
{ error: 'Failed to update workspace' },
{ status: 500 }
)
}
})

View File

@@ -0,0 +1,92 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db/prisma'
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
import { createWorkspaceSchema } from '@/lib/validation'
// GET /api/workspaces - List user's workspaces
export const GET = withAuth(async (req: AuthenticatedRequest) => {
try {
const memberships = await prisma.workspaceMember.findMany({
where: { userId: req.session.user.id },
include: {
workspace: {
select: {
id: true,
name: true,
clinicPhone: true,
emergencyPhone: true,
quietHoursStart: true,
quietHoursEnd: true,
largeTextMode: true,
createdAt: true,
},
},
},
})
return NextResponse.json({
workspaces: memberships.map((m) => ({
...m.workspace,
role: m.role,
})),
})
} catch (error) {
console.error('List workspaces error:', error)
return NextResponse.json(
{ error: 'Failed to list workspaces' },
{ status: 500 }
)
}
})
// POST /api/workspaces - Create a new workspace
export const POST = withAuth(async (req: AuthenticatedRequest) => {
try {
const body = await req.json()
const result = createWorkspaceSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid input', details: result.error.flatten() },
{ status: 400 }
)
}
const { name } = result.data
const workspace = await prisma.workspace.create({
data: {
name,
members: {
create: {
userId: req.session.user.id,
role: 'OWNER',
},
},
},
select: {
id: true,
name: true,
clinicPhone: true,
emergencyPhone: true,
quietHoursStart: true,
quietHoursEnd: true,
largeTextMode: true,
createdAt: true,
},
})
return NextResponse.json({
workspace: {
...workspace,
role: 'OWNER',
},
})
} catch (error) {
console.error('Create workspace error:', error)
return NextResponse.json(
{ error: 'Failed to create workspace' },
{ status: 500 }
)
}
})

211
src/app/globals.css Normal file
View File

@@ -0,0 +1,211 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 250 251 252;
--foreground: 31 38 49;
}
html {
-webkit-tap-highlight-color: transparent;
}
body {
@apply bg-background text-secondary-900 antialiased;
font-feature-settings: 'rlig' 1, 'calt' 1;
}
/* Large text mode */
.large-text body,
.large-text {
@apply text-lg;
}
.large-text h1 {
@apply text-3xl;
}
.large-text h2 {
@apply text-2xl;
}
.large-text h3 {
@apply text-xl;
}
.large-text .text-sm {
@apply text-base;
}
.large-text .text-xs {
@apply text-sm;
}
/* iOS safe areas */
.safe-area-top {
padding-top: env(safe-area-inset-top);
}
.safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom);
}
/* Focus styles for accessibility */
*:focus-visible {
@apply outline-none ring-2 ring-primary-500 ring-offset-2;
}
/* Better touch targets */
button,
a,
input,
select,
textarea {
@apply touch-manipulation;
}
/* Smooth scrolling */
@media (prefers-reduced-motion: no-preference) {
html {
scroll-behavior: smooth;
}
}
}
@layer components {
/* Primary taken button */
.btn-taken {
@apply bg-primary-500 hover:bg-primary-600 text-white font-semibold;
@apply py-3 px-6 rounded-button min-h-touch;
@apply shadow-button transition-all duration-200;
@apply active:scale-95;
}
/* Card styles */
.card-appointment {
@apply bg-surface rounded-card shadow-card p-4;
@apply border-l-4 border-primary-500;
}
.card-medication {
@apply bg-surface rounded-card shadow-card p-4;
@apply flex items-center justify-between;
}
/* Overdue styles */
.overdue {
@apply border-l-4 border-red-500 bg-red-50;
}
/* Timeline styles */
.timeline-item {
@apply relative pl-6 pb-4;
}
.timeline-item::before {
content: '';
@apply absolute left-0 top-2 w-2 h-2 rounded-full bg-primary-400;
}
.timeline-item::after {
content: '';
@apply absolute left-[3px] top-4 w-0.5 h-full bg-border;
}
.timeline-item:last-child::after {
@apply hidden;
}
}
@layer utilities {
/* Animation utilities */
.animate-in {
animation: animateIn 0.2s ease-out;
}
.animate-out {
animation: animateOut 0.15s ease-in forwards;
}
@keyframes animateIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes animateOut {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(8px);
}
}
.slide-in-from-bottom-4 {
animation: slideInFromBottom 0.2s ease-out;
}
.slide-out-to-bottom-4 {
animation: slideOutToBottom 0.15s ease-in forwards;
}
@keyframes slideInFromBottom {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideOutToBottom {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(16px);
}
}
.fade-in {
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.zoom-in-95 {
animation: zoomIn 0.2s ease-out;
}
@keyframes zoomIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
}

View File

@@ -0,0 +1,177 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter, useParams } from 'next/navigation'
import Link from 'next/link'
import { Heart, Users, AlertCircle } from 'lucide-react'
import { Button, Card, LoadingState, showToast } from '@/components/ui'
interface InviteInfo {
workspaceName: string
role: string
expiresAt: string
}
export default function InvitePage() {
const router = useRouter()
const params = useParams()
const token = params.token as string
const [loading, setLoading] = useState(true)
const [accepting, setAccepting] = useState(false)
const [inviteInfo, setInviteInfo] = useState<InviteInfo | null>(null)
const [error, setError] = useState('')
const [needsAuth, setNeedsAuth] = useState(false)
useEffect(() => {
const fetchInvite = async () => {
try {
const response = await fetch(`/api/invite/${token}`)
const data = await response.json()
if (!response.ok) {
setError(data.error || 'Invalid invite link')
return
}
setInviteInfo(data.invite)
} catch {
setError('Failed to load invite')
} finally {
setLoading(false)
}
}
fetchInvite()
}, [token])
const handleAccept = async () => {
setAccepting(true)
setError('')
try {
const response = await fetch(`/api/invite/${token}`, {
method: 'POST',
})
const data = await response.json()
if (response.status === 401) {
setNeedsAuth(true)
return
}
if (!response.ok) {
setError(data.error || 'Failed to accept invite')
return
}
showToast(`Joined ${data.workspace.name}!`, 'success')
router.push('/today')
router.refresh()
} catch {
setError('Something went wrong. Please try again.')
} finally {
setAccepting(false)
}
}
if (loading) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<LoadingState message="Loading invite..." />
</div>
)
}
if (error && !inviteInfo) {
return (
<div className="min-h-screen bg-background flex flex-col items-center justify-center p-4">
<div className="w-full max-w-sm text-center">
<div className="w-16 h-16 bg-red-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
<AlertCircle className="w-8 h-8 text-red-500" />
</div>
<h1 className="text-xl font-bold text-secondary-900 mb-2">
Invite Not Available
</h1>
<p className="text-secondary-500 mb-6">{error}</p>
<Link href="/login">
<Button fullWidth variant="secondary">
Go to Login
</Button>
</Link>
</div>
</div>
)
}
if (needsAuth) {
return (
<div className="min-h-screen bg-background flex flex-col items-center justify-center p-4">
<div className="w-full max-w-sm text-center">
<div className="w-16 h-16 bg-primary-500 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Heart className="w-8 h-8 text-white" />
</div>
<h1 className="text-xl font-bold text-secondary-900 mb-2">
Sign In Required
</h1>
<p className="text-secondary-500 mb-6">
Create an account or sign in to join {inviteInfo?.workspaceName}
</p>
<div className="space-y-3">
<Link href={`/register?redirect=/invite/${token}`}>
<Button fullWidth>Create Account</Button>
</Link>
<Link href={`/login?redirect=/invite/${token}`}>
<Button fullWidth variant="secondary">
Sign In
</Button>
</Link>
</div>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-background flex flex-col items-center justify-center p-4">
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-primary-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Users className="w-8 h-8 text-primary-600" />
</div>
<h1 className="text-xl font-bold text-secondary-900">
You're Invited!
</h1>
</div>
<Card className="mb-6 text-center">
<p className="text-secondary-600 mb-4">
You've been invited to join
</p>
<p className="text-xl font-bold text-secondary-900 mb-2">
{inviteInfo?.workspaceName}
</p>
<p className="text-sm text-secondary-500">
as {inviteInfo?.role === 'EDITOR' ? 'an Editor' : 'a Viewer'}
</p>
{error && (
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-button mt-4">
{error}
</p>
)}
</Card>
<Button onClick={handleAccept} fullWidth loading={accepting}>
Accept Invite
</Button>
<p className="text-center text-sm text-secondary-500 mt-4">
This invite expires on{' '}
{inviteInfo && new Date(inviteInfo.expiresAt).toLocaleDateString()}
</p>
</div>
</div>
)
}

43
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,43 @@
import type { Metadata, Viewport } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import { Toaster } from '@/components/ui'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'Next Step - Health Management',
description: 'A calm, reliable app to help manage appointments, medications, and notes for health care.',
manifest: '/manifest.json',
appleWebApp: {
capable: true,
statusBarStyle: 'default',
title: 'Next Step',
},
}
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false,
themeColor: '#3a9563',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<head>
<link rel="apple-touch-icon" href="/icon-192.png" />
</head>
<body className={inter.className}>
{children}
<Toaster />
</body>
</html>
)
}

99
src/app/login/page.tsx Normal file
View File

@@ -0,0 +1,99 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { Heart } from 'lucide-react'
import { Button, Input, Card, showToast } from '@/components/ui'
export default function LoginPage() {
const router = useRouter()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
const data = await response.json()
if (!response.ok) {
setError(data.error || 'Login failed')
return
}
showToast('Welcome back!', 'success')
router.push('/today')
router.refresh()
} catch {
setError('Something went wrong. Please try again.')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-background flex flex-col items-center justify-center p-4">
<div className="w-full max-w-sm">
{/* Logo */}
<div className="text-center mb-8">
<div className="w-16 h-16 bg-primary-500 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Heart className="w-8 h-8 text-white" />
</div>
<h1 className="text-2xl font-bold text-secondary-900">Next Step</h1>
<p className="text-secondary-500 mt-1">Health management made simple</p>
</div>
<Card className="mb-6">
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
autoComplete="email"
/>
<Input
label="Password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
autoComplete="current-password"
/>
{error && (
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-button">
{error}
</p>
)}
<Button type="submit" fullWidth loading={loading}>
Sign In
</Button>
</form>
</Card>
<p className="text-center text-secondary-500">
Don't have an account?{' '}
<Link href="/register" className="text-primary-600 font-medium hover:text-primary-700">
Create one
</Link>
</p>
</div>
</div>
)
}

158
src/app/onboarding/page.tsx Normal file
View File

@@ -0,0 +1,158 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Heart, Shield, ArrowRight } from 'lucide-react'
import { Button, Input, Card, showToast } from '@/components/ui'
export default function OnboardingPage() {
const router = useRouter()
const [step, setStep] = useState<'disclaimer' | 'workspace'>('disclaimer')
const [workspaceName, setWorkspaceName] = useState('')
const [clinicPhone, setClinicPhone] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const handleAcceptDisclaimer = () => {
setStep('workspace')
}
const handleCreateWorkspace = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
// Create workspace
const createRes = await fetch('/api/workspaces', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: workspaceName }),
})
const createData = await createRes.json()
if (!createRes.ok) {
throw new Error(createData.error || 'Failed to create workspace')
}
// Update with clinic phone if provided
if (clinicPhone.trim()) {
await fetch(`/api/workspaces/${createData.workspace.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ clinicPhone: clinicPhone.trim() }),
})
}
showToast('All set! Welcome to Next Step.', 'success')
router.push('/today')
router.refresh()
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong')
} finally {
setLoading(false)
}
}
if (step === 'disclaimer') {
return (
<div className="min-h-screen bg-background flex flex-col items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-amber-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Shield className="w-8 h-8 text-amber-600" />
</div>
<h1 className="text-2xl font-bold text-secondary-900">Important Notice</h1>
</div>
<Card className="mb-6">
<div className="space-y-4 text-secondary-700">
<p>
<strong>Next Step is a tracking tool only.</strong> It helps you and your family
stay organized with appointments and medications.
</p>
<p>
<strong className="text-red-600">This app does not provide medical advice.</strong>{' '}
Always consult your healthcare team for medical decisions.
</p>
<p>
<strong>For emergencies:</strong> Call 000 (Australia) or your local emergency
services immediately.
</p>
<p>
If you have questions about your treatment, contact your clinic directly using the
button we'll help you set up.
</p>
<div className="pt-4 border-t border-border">
<p className="text-sm text-secondary-500">
By continuing, you acknowledge that Next Step is for tracking purposes only and
does not replace professional medical advice.
</p>
</div>
</div>
</Card>
<Button onClick={handleAcceptDisclaimer} fullWidth>
I Understand
<ArrowRight className="w-5 h-5 ml-2" />
</Button>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-background flex flex-col items-center justify-center p-4">
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-primary-500 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Heart className="w-8 h-8 text-white" />
</div>
<h1 className="text-2xl font-bold text-secondary-900">Set Up Your Plan</h1>
<p className="text-secondary-500 mt-1">Create a workspace to get started</p>
</div>
<Card className="mb-6">
<form onSubmit={handleCreateWorkspace} className="space-y-4">
<Input
label="Workspace Name"
type="text"
value={workspaceName}
onChange={(e) => setWorkspaceName(e.target.value)}
placeholder="e.g., Grace's Plan"
helperText="This is how family members will identify this workspace"
required
/>
<Input
label="Clinic Phone Number"
type="tel"
value={clinicPhone}
onChange={(e) => setClinicPhone(e.target.value)}
placeholder="e.g., 08 9400 1234"
helperText="We'll add a 'Call Clinic' button for quick access"
/>
{error && (
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-button">
{error}
</p>
)}
<Button type="submit" fullWidth loading={loading}>
Create Workspace
</Button>
</form>
</Card>
<p className="text-center text-sm text-secondary-500">
You can add family members later from Settings
</p>
</div>
</div>
)
}

12
src/app/page.tsx Normal file
View File

@@ -0,0 +1,12 @@
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
export default async function HomePage() {
const session = await getSession()
if (!session) {
redirect('/login')
}
redirect('/today')
}

130
src/app/register/page.tsx Normal file
View File

@@ -0,0 +1,130 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { Heart } from 'lucide-react'
import { Button, Input, Card, showToast } from '@/components/ui'
export default function RegisterPage() {
const router = useRouter()
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (password !== confirmPassword) {
setError('Passwords do not match')
return
}
if (password.length < 8) {
setError('Password must be at least 8 characters')
return
}
setLoading(true)
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, password }),
})
const data = await response.json()
if (!response.ok) {
setError(data.error || 'Registration failed')
return
}
showToast('Account created! Let\'s get started.', 'success')
router.push('/onboarding')
router.refresh()
} catch {
setError('Something went wrong. Please try again.')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-background flex flex-col items-center justify-center p-4">
<div className="w-full max-w-sm">
{/* Logo */}
<div className="text-center mb-8">
<div className="w-16 h-16 bg-primary-500 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Heart className="w-8 h-8 text-white" />
</div>
<h1 className="text-2xl font-bold text-secondary-900">Create Account</h1>
<p className="text-secondary-500 mt-1">Join Next Step to get started</p>
</div>
<Card className="mb-6">
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Your Name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Grace"
required
autoComplete="name"
/>
<Input
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
autoComplete="email"
/>
<Input
label="Password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="At least 8 characters"
required
autoComplete="new-password"
/>
<Input
label="Confirm Password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm your password"
required
autoComplete="new-password"
/>
{error && (
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-button">
{error}
</p>
)}
<Button type="submit" fullWidth loading={loading}>
Create Account
</Button>
</form>
</Card>
<p className="text-center text-secondary-500">
Already have an account?{' '}
<Link href="/login" className="text-primary-600 font-medium hover:text-primary-700">
Sign in
</Link>
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,51 @@
'use client'
import { usePathname } from 'next/navigation'
import Link from 'next/link'
import { clsx } from 'clsx'
import { Home, Calendar, Pill, FileText, MoreHorizontal } from 'lucide-react'
const navItems = [
{ href: '/today', label: 'Today', icon: Home },
{ href: '/appointments', label: 'Appointments', icon: Calendar },
{ href: '/meds', label: 'Meds', icon: Pill },
{ href: '/notes', label: 'Notes', icon: FileText },
{ href: '/settings', label: 'More', icon: MoreHorizontal },
]
export function BottomNav() {
const pathname = usePathname()
return (
<nav className="fixed bottom-0 left-0 right-0 bg-surface border-t border-border safe-area-bottom z-40">
<div className="flex items-center justify-around max-w-lg mx-auto">
{navItems.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== '/today' && pathname.startsWith(item.href))
return (
<Link
key={item.href}
href={item.href}
className={clsx(
'flex flex-col items-center justify-center py-2 px-3 min-w-[64px] min-h-touch',
'transition-colors duration-200',
isActive
? 'text-primary-600'
: 'text-secondary-500 hover:text-secondary-700'
)}
>
<item.icon
className={clsx('w-6 h-6 mb-0.5', isActive && 'stroke-[2.5]')}
/>
<span className={clsx('text-xs', isActive && 'font-medium')}>
{item.label}
</span>
</Link>
)
})}
</div>
</nav>
)
}

View File

@@ -0,0 +1,98 @@
'use client'
import { useRouter } from 'next/navigation'
import { clsx } from 'clsx'
import { ChevronLeft, Plus } from 'lucide-react'
interface HeaderProps {
title: string
showBack?: boolean
backHref?: string
rightAction?: {
icon?: React.ReactNode
label?: string
onClick: () => void
}
className?: string
}
export function Header({
title,
showBack = false,
backHref,
rightAction,
className,
}: HeaderProps) {
const router = useRouter()
const handleBack = () => {
if (backHref) {
router.push(backHref)
} else {
router.back()
}
}
return (
<header
className={clsx(
'sticky top-0 bg-surface/95 backdrop-blur-sm border-b border-border z-30',
'safe-area-top',
className
)}
>
<div className="flex items-center justify-between h-14 px-4 max-w-lg mx-auto">
{/* Left side */}
<div className="w-10">
{showBack && (
<button
onClick={handleBack}
className="p-2 -ml-2 rounded-full hover:bg-muted transition-colors"
aria-label="Go back"
>
<ChevronLeft className="w-6 h-6 text-secondary-700" />
</button>
)}
</div>
{/* Title */}
<h1 className="text-lg font-semibold text-secondary-900 truncate">
{title}
</h1>
{/* Right side */}
<div className="w-10 flex justify-end">
{rightAction && (
<button
onClick={rightAction.onClick}
className="p-2 -mr-2 rounded-full hover:bg-muted transition-colors"
aria-label={rightAction.label}
>
{rightAction.icon || <Plus className="w-6 h-6 text-secondary-700" />}
</button>
)}
</div>
</div>
</header>
)
}
interface PageContainerProps {
children: React.ReactNode
className?: string
noPadding?: boolean
}
export function PageContainer({ children, className, noPadding = false }: PageContainerProps) {
return (
<main
className={clsx(
'min-h-screen bg-background pb-20', // pb-20 for bottom nav
!noPadding && 'px-4',
className
)}
>
<div className="max-w-lg mx-auto">{children}</div>
</main>
)
}

View File

@@ -0,0 +1,118 @@
'use client'
import { forwardRef } from 'react'
import { clsx } from 'clsx'
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'success'
size?: 'sm' | 'md' | 'lg'
loading?: boolean
fullWidth?: boolean
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
variant = 'primary',
size = 'md',
loading = false,
fullWidth = false,
className,
children,
disabled,
...props
},
ref
) => {
const baseStyles = clsx(
'inline-flex items-center justify-center font-semibold rounded-button transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-offset-2',
'disabled:opacity-50 disabled:cursor-not-allowed',
'active:scale-[0.98]',
'min-h-touch' // Touch-friendly minimum height
)
const variantStyles = {
primary: clsx(
'bg-primary-600 text-white',
'hover:bg-primary-700',
'focus:ring-primary-500',
'shadow-button'
),
secondary: clsx(
'bg-secondary-100 text-secondary-800',
'hover:bg-secondary-200',
'focus:ring-secondary-500',
'border border-secondary-200'
),
ghost: clsx(
'bg-transparent text-secondary-700',
'hover:bg-secondary-100',
'focus:ring-secondary-500'
),
danger: clsx(
'bg-red-600 text-white',
'hover:bg-red-700',
'focus:ring-red-500',
'shadow-button'
),
success: clsx(
'bg-primary-500 text-white',
'hover:bg-primary-600',
'focus:ring-primary-400',
'shadow-button'
),
}
const sizeStyles = {
sm: 'px-3 py-2 text-sm min-h-[36px]',
md: 'px-4 py-2.5 text-base min-h-touch',
lg: 'px-6 py-3 text-lg min-h-[56px]',
}
return (
<button
ref={ref}
disabled={disabled || loading}
className={clsx(
baseStyles,
variantStyles[variant],
sizeStyles[size],
fullWidth && 'w-full',
className
)}
{...props}
>
{loading ? (
<>
<svg
className="animate-spin -ml-1 mr-2 h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Loading...
</>
) : (
children
)}
</button>
)
}
)
Button.displayName = 'Button'

103
src/components/ui/card.tsx Normal file
View File

@@ -0,0 +1,103 @@
'use client'
import { clsx } from 'clsx'
interface CardProps {
children: React.ReactNode
className?: string
variant?: 'default' | 'elevated' | 'outline'
padding?: 'none' | 'sm' | 'md' | 'lg'
onClick?: () => void
}
export function Card({
children,
className,
variant = 'default',
padding = 'md',
onClick,
}: CardProps) {
const baseStyles = 'rounded-card bg-surface'
const variantStyles = {
default: 'shadow-card',
elevated: 'shadow-card-hover',
outline: 'border border-border',
}
const paddingStyles = {
none: '',
sm: 'p-3',
md: 'p-4 md:p-5',
lg: 'p-5 md:p-6',
}
const clickableStyles = onClick
? 'cursor-pointer hover:shadow-card-hover transition-shadow duration-200'
: ''
return (
<div
className={clsx(
baseStyles,
variantStyles[variant],
paddingStyles[padding],
clickableStyles,
className
)}
onClick={onClick}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
onKeyDown={
onClick
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onClick()
}
}
: undefined
}
>
{children}
</div>
)
}
interface CardHeaderProps {
children: React.ReactNode
className?: string
}
export function CardHeader({ children, className }: CardHeaderProps) {
return (
<div className={clsx('flex items-center justify-between mb-3', className)}>
{children}
</div>
)
}
interface CardTitleProps {
children: React.ReactNode
className?: string
as?: 'h1' | 'h2' | 'h3' | 'h4'
}
export function CardTitle({ children, className, as: Component = 'h3' }: CardTitleProps) {
return (
<Component
className={clsx('text-lg font-semibold text-secondary-900', className)}
>
{children}
</Component>
)
}
interface CardContentProps {
children: React.ReactNode
className?: string
}
export function CardContent({ children, className }: CardContentProps) {
return <div className={clsx('text-secondary-700', className)}>{children}</div>
}

View File

@@ -0,0 +1,6 @@
export { Button } from './button'
export { Card, CardHeader, CardTitle, CardContent } from './card'
export { Input, Textarea, Select } from './input'
export { Modal, ConfirmModal } from './modal'
export { LoadingState, EmptyState, ErrorState, SyncBanner } from './states'
export { Toaster, showToast, showUndoToast } from './toast'

163
src/components/ui/input.tsx Normal file
View File

@@ -0,0 +1,163 @@
'use client'
import { forwardRef } from 'react'
import { clsx } from 'clsx'
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string
error?: string
helperText?: string
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, error, helperText, className, id, ...props }, ref) => {
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-')
return (
<div className="w-full">
{label && (
<label
htmlFor={inputId}
className="block text-sm font-medium text-secondary-700 mb-1.5"
>
{label}
</label>
)}
<input
ref={ref}
id={inputId}
className={clsx(
'w-full rounded-button border px-4 py-3 text-base',
'transition-colors duration-200',
'placeholder:text-secondary-400',
'focus:outline-none focus:ring-2 focus:ring-offset-0',
'min-h-touch',
error
? 'border-red-300 focus:border-red-500 focus:ring-red-200'
: 'border-border focus:border-primary-500 focus:ring-primary-200',
'disabled:bg-muted disabled:cursor-not-allowed',
className
)}
{...props}
/>
{error && (
<p className="mt-1.5 text-sm text-red-600" role="alert">
{error}
</p>
)}
{helperText && !error && (
<p className="mt-1.5 text-sm text-secondary-500">{helperText}</p>
)}
</div>
)
}
)
Input.displayName = 'Input'
interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string
error?: string
helperText?: string
}
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
({ label, error, helperText, className, id, ...props }, ref) => {
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-')
return (
<div className="w-full">
{label && (
<label
htmlFor={inputId}
className="block text-sm font-medium text-secondary-700 mb-1.5"
>
{label}
</label>
)}
<textarea
ref={ref}
id={inputId}
className={clsx(
'w-full rounded-button border px-4 py-3 text-base',
'transition-colors duration-200',
'placeholder:text-secondary-400',
'focus:outline-none focus:ring-2 focus:ring-offset-0',
'resize-none',
error
? 'border-red-300 focus:border-red-500 focus:ring-red-200'
: 'border-border focus:border-primary-500 focus:ring-primary-200',
'disabled:bg-muted disabled:cursor-not-allowed',
className
)}
{...props}
/>
{error && (
<p className="mt-1.5 text-sm text-red-600" role="alert">
{error}
</p>
)}
{helperText && !error && (
<p className="mt-1.5 text-sm text-secondary-500">{helperText}</p>
)}
</div>
)
}
)
Textarea.displayName = 'Textarea'
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
label?: string
error?: string
options: { value: string; label: string }[]
}
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
({ label, error, options, className, id, ...props }, ref) => {
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-')
return (
<div className="w-full">
{label && (
<label
htmlFor={inputId}
className="block text-sm font-medium text-secondary-700 mb-1.5"
>
{label}
</label>
)}
<select
ref={ref}
id={inputId}
className={clsx(
'w-full rounded-button border px-4 py-3 text-base',
'transition-colors duration-200',
'focus:outline-none focus:ring-2 focus:ring-offset-0',
'min-h-touch',
'bg-white',
error
? 'border-red-300 focus:border-red-500 focus:ring-red-200'
: 'border-border focus:border-primary-500 focus:ring-primary-200',
'disabled:bg-muted disabled:cursor-not-allowed',
className
)}
{...props}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{error && (
<p className="mt-1.5 text-sm text-red-600" role="alert">
{error}
</p>
)}
</div>
)
}
)
Select.displayName = 'Select'

159
src/components/ui/modal.tsx Normal file
View File

@@ -0,0 +1,159 @@
'use client'
import { useEffect, useRef } from 'react'
import { clsx } from 'clsx'
import { X } from 'lucide-react'
import { Button } from './button'
interface ModalProps {
isOpen: boolean
onClose: () => void
title?: string
children: React.ReactNode
size?: 'sm' | 'md' | 'lg' | 'full'
showCloseButton?: boolean
}
export function Modal({
isOpen,
onClose,
title,
children,
size = 'md',
showCloseButton = true,
}: ModalProps) {
const overlayRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
return () => {
document.body.style.overflow = ''
}
}, [isOpen])
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose()
}
}
if (isOpen) {
document.addEventListener('keydown', handleEscape)
}
return () => {
document.removeEventListener('keydown', handleEscape)
}
}, [isOpen, onClose])
if (!isOpen) return null
const sizeStyles = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
full: 'max-w-full mx-4',
}
return (
<div
ref={overlayRef}
className="fixed inset-0 z-50 flex items-end md:items-center justify-center"
onClick={(e) => {
if (e.target === overlayRef.current) {
onClose()
}
}}
>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
{/* Content */}
<div
ref={contentRef}
className={clsx(
'relative bg-surface rounded-t-2xl md:rounded-card w-full',
'max-h-[90vh] overflow-y-auto',
'animate-in slide-in-from-bottom-4 md:fade-in md:zoom-in-95 duration-200',
sizeStyles[size]
)}
role="dialog"
aria-modal="true"
aria-labelledby={title ? 'modal-title' : undefined}
>
{/* Header */}
{(title || showCloseButton) && (
<div className="sticky top-0 bg-surface flex items-center justify-between p-4 border-b border-border">
{title && (
<h2 id="modal-title" className="text-lg font-semibold text-secondary-900">
{title}
</h2>
)}
{showCloseButton && (
<button
onClick={onClose}
className="p-2 rounded-full hover:bg-muted transition-colors -mr-2"
aria-label="Close modal"
>
<X className="w-5 h-5 text-secondary-500" />
</button>
)}
</div>
)}
{/* Body */}
<div className="p-4">{children}</div>
</div>
</div>
)
}
interface ConfirmModalProps {
isOpen: boolean
onClose: () => void
onConfirm: () => void
title: string
message: string
confirmText?: string
cancelText?: string
variant?: 'danger' | 'primary'
loading?: boolean
}
export function ConfirmModal({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = 'Confirm',
cancelText = 'Cancel',
variant = 'primary',
loading = false,
}: ConfirmModalProps) {
return (
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm">
<p className="text-secondary-600 mb-6">{message}</p>
<div className="flex gap-3">
<Button variant="secondary" onClick={onClose} fullWidth disabled={loading}>
{cancelText}
</Button>
<Button
variant={variant === 'danger' ? 'danger' : 'primary'}
onClick={onConfirm}
fullWidth
loading={loading}
>
{confirmText}
</Button>
</div>
</Modal>
)
}

View File

@@ -0,0 +1,123 @@
'use client'
import { clsx } from 'clsx'
import { Calendar, Pill, FileText, AlertCircle, RefreshCw } from 'lucide-react'
import { Button } from './button'
interface LoadingStateProps {
message?: string
className?: string
}
export function LoadingState({ message = 'Loading...', className }: LoadingStateProps) {
return (
<div className={clsx('flex flex-col items-center justify-center py-12', className)}>
<div className="relative w-12 h-12 mb-4">
<div className="absolute inset-0 rounded-full border-4 border-muted" />
<div className="absolute inset-0 rounded-full border-4 border-primary-500 border-t-transparent animate-spin" />
</div>
<p className="text-secondary-500">{message}</p>
</div>
)
}
interface EmptyStateProps {
type: 'appointments' | 'medications' | 'notes' | 'general'
title: string
description?: string
action?: {
label: string
onClick: () => void
}
className?: string
}
export function EmptyState({
type,
title,
description,
action,
className,
}: EmptyStateProps) {
const icons = {
appointments: Calendar,
medications: Pill,
notes: FileText,
general: AlertCircle,
}
const Icon = icons[type]
return (
<div className={clsx('flex flex-col items-center justify-center py-12 px-4 text-center', className)}>
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
<Icon className="w-8 h-8 text-secondary-400" />
</div>
<h3 className="text-lg font-semibold text-secondary-900 mb-1">{title}</h3>
{description && (
<p className="text-secondary-500 max-w-xs mb-4">{description}</p>
)}
{action && (
<Button onClick={action.onClick} variant="primary">
{action.label}
</Button>
)}
</div>
)
}
interface ErrorStateProps {
title?: string
message?: string
onRetry?: () => void
className?: string
}
export function ErrorState({
title = 'Something went wrong',
message = 'We had trouble loading this content.',
onRetry,
className,
}: ErrorStateProps) {
return (
<div className={clsx('flex flex-col items-center justify-center py-12 px-4 text-center', className)}>
<div className="w-16 h-16 rounded-full bg-red-50 flex items-center justify-center mb-4">
<AlertCircle className="w-8 h-8 text-red-500" />
</div>
<h3 className="text-lg font-semibold text-secondary-900 mb-1">{title}</h3>
<p className="text-secondary-500 max-w-xs mb-4">{message}</p>
{onRetry && (
<Button onClick={onRetry} variant="secondary">
<RefreshCw className="w-4 h-4 mr-2" />
Try again
</Button>
)}
</div>
)
}
interface SyncBannerProps {
hasConflict: boolean
onDismiss: () => void
}
export function SyncBanner({ hasConflict, onDismiss }: SyncBannerProps) {
if (!hasConflict) return null
return (
<div className="bg-amber-50 border-b border-amber-200 px-4 py-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<RefreshCw className="w-4 h-4 text-amber-600" />
<span className="text-sm text-amber-800">
Updated on another device
</span>
</div>
<button
onClick={onDismiss}
className="text-sm text-amber-600 hover:text-amber-800 font-medium"
>
Dismiss
</button>
</div>
)
}

View File

@@ -0,0 +1,79 @@
'use client'
import toast, { Toaster as HotToaster } from 'react-hot-toast'
import { CheckCircle, XCircle, AlertCircle, X } from 'lucide-react'
export function Toaster() {
return (
<HotToaster
position="bottom-center"
containerStyle={{
bottom: 80, // Above bottom nav
}}
toastOptions={{
duration: 4000,
style: {
background: '#1f2631',
color: '#fff',
padding: '12px 16px',
borderRadius: '12px',
fontSize: '0.9375rem',
maxWidth: '380px',
},
}}
/>
)
}
export function showToast(message: string, type: 'success' | 'error' | 'info' = 'info') {
const icons = {
success: <CheckCircle className="w-5 h-5 text-green-400" />,
error: <XCircle className="w-5 h-5 text-red-400" />,
info: <AlertCircle className="w-5 h-5 text-blue-400" />,
}
toast.custom(
(t) => (
<div
className={`flex items-center gap-3 px-4 py-3 bg-secondary-900 text-white rounded-card shadow-lg ${
t.visible ? 'animate-in slide-in-from-bottom-4' : 'animate-out slide-out-to-bottom-4'
}`}
>
{icons[type]}
<span className="flex-1">{message}</span>
<button
onClick={() => toast.dismiss(t.id)}
className="p-1 hover:bg-white/10 rounded-full transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
),
{ duration: 4000 }
)
}
export function showUndoToast(message: string, onUndo: () => void) {
toast.custom(
(t) => (
<div
className={`flex items-center gap-3 px-4 py-3 bg-secondary-900 text-white rounded-card shadow-lg ${
t.visible ? 'animate-in slide-in-from-bottom-4' : 'animate-out slide-out-to-bottom-4'
}`}
>
<CheckCircle className="w-5 h-5 text-green-400" />
<span className="flex-1">{message}</span>
<button
onClick={() => {
onUndo()
toast.dismiss(t.id)
}}
className="px-3 py-1 bg-white/10 hover:bg-white/20 rounded-button text-sm font-medium transition-colors"
>
Undo
</button>
</div>
),
{ duration: 5000 }
)
}

17
src/lib/auth/index.ts Normal file
View File

@@ -0,0 +1,17 @@
export { hashPassword, verifyPassword } from './password'
export {
createSession,
getSession,
deleteSession,
deleteAllUserSessions,
getSessionCookieConfig,
getSessionCookieClearConfig,
type SessionUser,
type SessionData,
} from './session'
export {
checkLoginRateLimit,
recordLoginAttempt,
checkApiRateLimit,
} from './rate-limit'
export { withAuth, withRateLimit, type AuthenticatedRequest } from './middleware'

View File

@@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from 'next/server'
import { getSession, SessionData } from './session'
import { checkApiRateLimit } from './rate-limit'
export interface AuthenticatedRequest extends NextRequest {
session: SessionData
}
type RouteHandler = (
req: NextRequest,
context: { params: Promise<Record<string, string>> }
) => Promise<NextResponse>
type AuthenticatedRouteHandler = (
req: AuthenticatedRequest,
context: { params: Promise<Record<string, string>> }
) => Promise<NextResponse>
export function withAuth(handler: AuthenticatedRouteHandler): RouteHandler {
return async (req: NextRequest, context) => {
// Rate limiting
const ip = req.headers.get('x-forwarded-for')?.split(',')[0] || 'unknown'
const rateLimit = checkApiRateLimit(ip)
if (!rateLimit.allowed) {
return NextResponse.json(
{ error: 'Too many requests. Please try again later.' },
{
status: 429,
headers: {
'X-RateLimit-Remaining': '0',
'Retry-After': '60',
},
}
)
}
// Authentication
const session = await getSession()
if (!session) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
// Attach session to request
const authenticatedReq = req as AuthenticatedRequest
authenticatedReq.session = session
return handler(authenticatedReq, context)
}
}
export function withRateLimit(handler: RouteHandler): RouteHandler {
return async (req: NextRequest, context) => {
const ip = req.headers.get('x-forwarded-for')?.split(',')[0] || 'unknown'
const rateLimit = checkApiRateLimit(ip)
if (!rateLimit.allowed) {
return NextResponse.json(
{ error: 'Too many requests. Please try again later.' },
{
status: 429,
headers: {
'X-RateLimit-Remaining': '0',
'Retry-After': '60',
},
}
)
}
const response = await handler(req, context)
response.headers.set('X-RateLimit-Remaining', rateLimit.remaining.toString())
return response
}
}

21
src/lib/auth/password.ts Normal file
View File

@@ -0,0 +1,21 @@
import * as argon2 from 'argon2'
export async function hashPassword(password: string): Promise<string> {
return argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536,
timeCost: 3,
parallelism: 4,
})
}
export async function verifyPassword(
hash: string,
password: string
): Promise<boolean> {
try {
return await argon2.verify(hash, password)
} catch {
return false
}
}

View File

@@ -0,0 +1,95 @@
import { prisma } from '@/lib/db/prisma'
const LOGIN_MAX_ATTEMPTS = parseInt(process.env.LOGIN_MAX_ATTEMPTS || '5', 10)
const LOGIN_LOCKOUT_MINUTES = parseInt(process.env.LOGIN_LOCKOUT_MINUTES || '15', 10)
export interface RateLimitResult {
allowed: boolean
remainingAttempts: number
lockoutMinutes?: number
}
export async function checkLoginRateLimit(
email: string,
ipAddress?: string
): Promise<RateLimitResult> {
const windowStart = new Date()
windowStart.setMinutes(windowStart.getMinutes() - LOGIN_LOCKOUT_MINUTES)
// Count recent failed attempts for this email
const recentAttempts = await prisma.loginAttempt.count({
where: {
email: email.toLowerCase(),
success: false,
createdAt: { gte: windowStart },
},
})
if (recentAttempts >= LOGIN_MAX_ATTEMPTS) {
return {
allowed: false,
remainingAttempts: 0,
lockoutMinutes: LOGIN_LOCKOUT_MINUTES,
}
}
return {
allowed: true,
remainingAttempts: LOGIN_MAX_ATTEMPTS - recentAttempts,
}
}
export async function recordLoginAttempt(
email: string,
success: boolean,
ipAddress?: string
): Promise<void> {
await prisma.loginAttempt.create({
data: {
email: email.toLowerCase(),
success,
ipAddress,
},
})
// Clean up old attempts (older than 24 hours)
const cutoff = new Date()
cutoff.setHours(cutoff.getHours() - 24)
await prisma.loginAttempt.deleteMany({
where: { createdAt: { lt: cutoff } },
}).catch(() => {})
}
// Simple in-memory rate limiter for API endpoints
const requestCounts = new Map<string, { count: number; resetAt: number }>()
const RATE_LIMIT_MAX_REQUESTS = parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100', 10)
const RATE_LIMIT_WINDOW_MS = parseInt(process.env.RATE_LIMIT_WINDOW_MS || '60000', 10)
export function checkApiRateLimit(identifier: string): { allowed: boolean; remaining: number } {
const now = Date.now()
const entry = requestCounts.get(identifier)
if (!entry || entry.resetAt < now) {
requestCounts.set(identifier, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS })
return { allowed: true, remaining: RATE_LIMIT_MAX_REQUESTS - 1 }
}
if (entry.count >= RATE_LIMIT_MAX_REQUESTS) {
return { allowed: false, remaining: 0 }
}
entry.count++
return { allowed: true, remaining: RATE_LIMIT_MAX_REQUESTS - entry.count }
}
// Clean up old entries periodically
setInterval(() => {
const now = Date.now()
for (const [key, value] of requestCounts.entries()) {
if (value.resetAt < now) {
requestCounts.delete(key)
}
}
}, 60000)

117
src/lib/auth/session.ts Normal file
View File

@@ -0,0 +1,117 @@
import { cookies } from 'next/headers'
import { prisma } from '@/lib/db/prisma'
import { nanoid } from 'nanoid'
const SESSION_COOKIE_NAME = 'nextstep_session'
const SESSION_MAX_AGE_DAYS = parseInt(process.env.SESSION_MAX_AGE_DAYS || '30', 10)
export interface SessionUser {
id: string
email: string
name: string
}
export interface SessionData {
user: SessionUser
sessionId: string
}
export async function createSession(
userId: string,
userAgent?: string,
ipAddress?: string
): Promise<string> {
const token = nanoid(64)
const expiresAt = new Date()
expiresAt.setDate(expiresAt.getDate() + SESSION_MAX_AGE_DAYS)
await prisma.session.create({
data: {
userId,
token,
expiresAt,
userAgent,
ipAddress,
},
})
return token
}
export async function getSession(): Promise<SessionData | null> {
const cookieStore = await cookies()
const sessionToken = cookieStore.get(SESSION_COOKIE_NAME)?.value
if (!sessionToken) {
return null
}
const session = await prisma.session.findUnique({
where: { token: sessionToken },
include: {
user: {
select: {
id: true,
email: true,
name: true,
},
},
},
})
if (!session || session.expiresAt < new Date()) {
// Clean up expired session
if (session) {
await prisma.session.delete({ where: { id: session.id } }).catch(() => {})
}
return null
}
return {
user: session.user,
sessionId: session.id,
}
}
export async function deleteSession(sessionId: string): Promise<void> {
await prisma.session.delete({ where: { id: sessionId } }).catch(() => {})
}
export async function deleteAllUserSessions(userId: string): Promise<void> {
await prisma.session.deleteMany({ where: { userId } })
}
export function setSessionCookie(token: string): void {
const expiresAt = new Date()
expiresAt.setDate(expiresAt.getDate() + SESSION_MAX_AGE_DAYS)
// Note: This is for server-side use. The actual cookie setting
// happens in the API route response
}
export function getSessionCookieConfig(token: string) {
const expiresAt = new Date()
expiresAt.setDate(expiresAt.getDate() + SESSION_MAX_AGE_DAYS)
return {
name: SESSION_COOKIE_NAME,
value: token,
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
expires: expiresAt,
path: '/',
}
}
export function getSessionCookieClearConfig() {
return {
name: SESSION_COOKIE_NAME,
value: '',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
expires: new Date(0),
path: '/',
}
}

12
src/lib/db/prisma.ts Normal file
View File

@@ -0,0 +1,12 @@
import { PrismaClient } from '@prisma/client'
declare global {
// eslint-disable-next-line no-var
var prisma: PrismaClient | undefined
}
export const prisma = globalThis.prisma || new PrismaClient()
if (process.env.NODE_ENV !== 'production') {
globalThis.prisma = prisma
}

View File

@@ -0,0 +1,24 @@
import { prisma } from './prisma'
import type { WorkspaceRole } from '@prisma/client'
export async function checkWorkspaceAccess(
workspaceId: string,
userId: string,
requiredRoles: WorkspaceRole[] = ['OWNER', 'EDITOR', 'VIEWER']
): Promise<{ role: WorkspaceRole } | null> {
const member = await prisma.workspaceMember.findUnique({
where: {
workspaceId_userId: { workspaceId, userId },
},
})
if (!member || !requiredRoles.includes(member.role)) {
return null
}
return { role: member.role }
}
export function canEdit(role: WorkspaceRole): boolean {
return role === 'OWNER' || role === 'EDITOR'
}

View File

@@ -0,0 +1,337 @@
import { describe, it, expect, beforeEach } from 'vitest'
import {
calculateMedicationDueStatus,
calculateAllMedicationsDue,
getMedicationsDueSoon,
formatTimeUntil,
} from './calculator'
import type { Medication, DoseLog, ScheduleData } from './types'
// Helper to create a medication
function createMed(
id: string,
name: string,
scheduleData: ScheduleData,
overrides: Partial<Medication> = {}
): Medication {
return {
id,
name,
instructions: null,
scheduleType: scheduleData.type,
scheduleData,
startDate: null,
endDate: null,
active: true,
...overrides,
}
}
// Helper to create a dose log
function createDose(
medicationId: string,
takenAt: Date,
undoneAt: Date | null = null
): DoseLog {
return {
id: `dose-${Date.now()}-${Math.random()}`,
medicationId,
takenAt,
undoneAt,
}
}
describe('Scheduling Calculator', () => {
describe('FIXED_TIMES schedule', () => {
const med = createMed('med1', 'Morning Med', {
type: 'FIXED_TIMES',
times: ['08:00', '20:00'],
})
it('returns first scheduled time when no doses taken', () => {
// At 7am, should show 8am as next due
const now = new Date('2024-01-15T07:00:00+08:00')
const status = calculateMedicationDueStatus(med, now, [])
expect(status.nextDueAt).toBeDefined()
expect(status.nextDueAt!.getHours()).toBe(8)
expect(status.nextDueAt!.getMinutes()).toBe(0)
expect(status.isOverdue).toBe(false)
})
it('returns second time after first dose is taken', () => {
const now = new Date('2024-01-15T10:00:00+08:00')
const doses = [createDose('med1', new Date('2024-01-15T08:05:00+08:00'))]
const status = calculateMedicationDueStatus(med, now, doses)
expect(status.nextDueAt).toBeDefined()
expect(status.nextDueAt!.getHours()).toBe(20)
expect(status.nextDueAt!.getMinutes()).toBe(0)
})
it('returns first time tomorrow after all doses taken', () => {
const now = new Date('2024-01-15T21:00:00+08:00')
const doses = [
createDose('med1', new Date('2024-01-15T08:05:00+08:00')),
createDose('med1', new Date('2024-01-15T20:10:00+08:00')),
]
const status = calculateMedicationDueStatus(med, now, doses)
expect(status.nextDueAt).toBeDefined()
expect(status.nextDueAt!.getDate()).toBe(16)
expect(status.nextDueAt!.getHours()).toBe(8)
})
it('marks as overdue after grace period', () => {
// At 9:30am (90 mins after 8am dose), should be overdue
const now = new Date('2024-01-15T09:30:00+08:00')
const status = calculateMedicationDueStatus(med, now, [])
expect(status.isOverdue).toBe(true)
expect(status.overdueMinutes).toBeGreaterThan(60)
})
it('ignores undone doses', () => {
const now = new Date('2024-01-15T10:00:00+08:00')
const doses = [
createDose('med1', new Date('2024-01-15T08:05:00+08:00'), new Date('2024-01-15T08:10:00+08:00')),
]
const status = calculateMedicationDueStatus(med, now, doses)
// Should still show 8am as due since the dose was undone
expect(status.isOverdue).toBe(true)
})
})
describe('INTERVAL schedule', () => {
const med = createMed('med2', 'Every 8 Hours', {
type: 'INTERVAL',
hours: 8,
startTime: '08:00',
})
it('returns start time when no doses taken and before start time', () => {
const now = new Date('2024-01-15T06:00:00+08:00')
const status = calculateMedicationDueStatus(med, now, [])
expect(status.nextDueAt).toBeDefined()
expect(status.nextDueAt!.getHours()).toBe(8)
})
it('calculates next interval from last dose', () => {
const now = new Date('2024-01-15T12:00:00+08:00')
const doses = [createDose('med2', new Date('2024-01-15T08:00:00+08:00'))]
const status = calculateMedicationDueStatus(med, now, doses)
// 8 hours after 8am = 4pm
expect(status.nextDueAt).toBeDefined()
expect(status.nextDueAt!.getHours()).toBe(16)
})
it('handles doses taken at non-scheduled times', () => {
const now = new Date('2024-01-15T15:00:00+08:00')
const doses = [createDose('med2', new Date('2024-01-15T10:00:00+08:00'))]
const status = calculateMedicationDueStatus(med, now, doses)
// 8 hours after 10am = 6pm
expect(status.nextDueAt).toBeDefined()
expect(status.nextDueAt!.getHours()).toBe(18)
})
})
describe('WEEKDAYS schedule', () => {
const med = createMed('med3', 'Mon/Wed/Fri', {
type: 'WEEKDAYS',
days: [1, 3, 5], // Monday, Wednesday, Friday
time: '09:00',
})
it('returns today time if today is a scheduled day', () => {
// Monday
const now = new Date('2024-01-15T07:00:00+08:00') // This is a Monday
const status = calculateMedicationDueStatus(med, now, [])
expect(status.nextDueAt).toBeDefined()
expect(status.nextDueAt!.getDate()).toBe(15)
expect(status.nextDueAt!.getHours()).toBe(9)
})
it('returns next scheduled day if today is not scheduled', () => {
// Tuesday - should return Wednesday
const now = new Date('2024-01-16T10:00:00+08:00')
const status = calculateMedicationDueStatus(med, now, [])
expect(status.nextDueAt).toBeDefined()
expect(status.nextDueAt!.getDate()).toBe(17) // Wednesday
})
it('skips to next scheduled day after taking dose', () => {
// Monday at noon, dose already taken
const now = new Date('2024-01-15T12:00:00+08:00')
const doses = [createDose('med3', new Date('2024-01-15T09:15:00+08:00'))]
const status = calculateMedicationDueStatus(med, now, doses)
expect(status.nextDueAt).toBeDefined()
expect(status.nextDueAt!.getDate()).toBe(17) // Wednesday
})
})
describe('PRN schedule', () => {
const med = createMed('med4', 'Pain Relief', {
type: 'PRN',
minHoursBetween: 4,
})
it('is available when no doses taken', () => {
const now = new Date('2024-01-15T10:00:00+08:00')
const status = calculateMedicationDueStatus(med, now, [])
expect(status.isPRN).toBe(true)
expect(status.prnAvailable).toBe(true)
expect(status.prnAvailableAt).toBeNull()
})
it('is in cooldown after recent dose', () => {
const now = new Date('2024-01-15T10:00:00+08:00')
const doses = [createDose('med4', new Date('2024-01-15T08:00:00+08:00'))]
const status = calculateMedicationDueStatus(med, now, doses)
expect(status.prnAvailable).toBe(false)
expect(status.prnAvailableAt).toBeDefined()
// Available at 12pm (4 hours after 8am)
expect(status.prnAvailableAt!.getHours()).toBe(12)
})
it('becomes available after cooldown period', () => {
const now = new Date('2024-01-15T14:00:00+08:00')
const doses = [createDose('med4', new Date('2024-01-15T08:00:00+08:00'))]
const status = calculateMedicationDueStatus(med, now, doses)
expect(status.prnAvailable).toBe(true)
})
})
describe('Date range filtering', () => {
it('excludes medication before start date', () => {
const med = createMed('med5', 'Future Med', {
type: 'FIXED_TIMES',
times: ['08:00'],
}, {
startDate: new Date('2024-01-20'),
})
const now = new Date('2024-01-15T08:00:00+08:00')
const status = calculateMedicationDueStatus(med, now, [])
expect(status.nextDueAt).toBeNull()
})
it('excludes medication after end date', () => {
const med = createMed('med6', 'Ended Med', {
type: 'FIXED_TIMES',
times: ['08:00'],
}, {
endDate: new Date('2024-01-10'),
})
const now = new Date('2024-01-15T08:00:00+08:00')
const status = calculateMedicationDueStatus(med, now, [])
expect(status.nextDueAt).toBeNull()
})
it('excludes inactive medication', () => {
const med = createMed('med7', 'Inactive Med', {
type: 'FIXED_TIMES',
times: ['08:00'],
}, {
active: false,
})
const now = new Date('2024-01-15T07:00:00+08:00')
const status = calculateMedicationDueStatus(med, now, [])
expect(status.nextDueAt).toBeNull()
})
})
describe('calculateAllMedicationsDue', () => {
it('sorts medications by priority', () => {
const meds = [
createMed('med1', 'Due Later', { type: 'FIXED_TIMES', times: ['16:00'] }),
createMed('med2', 'Overdue', { type: 'FIXED_TIMES', times: ['06:00'] }),
createMed('med3', 'Due Soon', { type: 'FIXED_TIMES', times: ['10:00'] }),
]
const now = new Date('2024-01-15T09:30:00+08:00')
const statuses = calculateAllMedicationsDue(meds, now, [])
// Overdue should be first
expect(statuses[0].medication.name).toBe('Overdue')
// Then due soon
expect(statuses[1].medication.name).toBe('Due Soon')
// Then due later
expect(statuses[2].medication.name).toBe('Due Later')
})
})
describe('getMedicationsDueSoon', () => {
it('returns only medications due within timeframe', () => {
const meds = [
createMed('med1', 'Due in 30min', { type: 'FIXED_TIMES', times: ['10:00'] }),
createMed('med2', 'Due in 3 hours', { type: 'FIXED_TIMES', times: ['12:30'] }),
]
const now = new Date('2024-01-15T09:30:00+08:00')
const dueSoon = getMedicationsDueSoon(meds, now, [], 60)
expect(dueSoon).toHaveLength(1)
expect(dueSoon[0].medication.name).toBe('Due in 30min')
})
it('includes PRN medications that are available', () => {
const meds = [
createMed('med1', 'PRN Available', { type: 'PRN', minHoursBetween: 4 }),
createMed('med2', 'PRN In Cooldown', { type: 'PRN', minHoursBetween: 4 }),
]
const now = new Date('2024-01-15T10:00:00+08:00')
const doses = [createDose('med2', new Date('2024-01-15T08:00:00+08:00'))]
const dueSoon = getMedicationsDueSoon(meds, now, doses, 60)
expect(dueSoon).toHaveLength(1)
expect(dueSoon[0].medication.name).toBe('PRN Available')
})
})
describe('formatTimeUntil', () => {
it('formats future times correctly', () => {
const now = new Date('2024-01-15T10:00:00+08:00')
expect(formatTimeUntil(new Date('2024-01-15T10:30:00+08:00'), now)).toBe('in 30m')
expect(formatTimeUntil(new Date('2024-01-15T12:00:00+08:00'), now)).toBe('in 2h')
expect(formatTimeUntil(new Date('2024-01-15T12:30:00+08:00'), now)).toBe('in 2h 30m')
})
it('formats overdue times correctly', () => {
const now = new Date('2024-01-15T10:00:00+08:00')
expect(formatTimeUntil(new Date('2024-01-15T09:30:00+08:00'), now)).toBe('30m overdue')
expect(formatTimeUntil(new Date('2024-01-15T08:00:00+08:00'), now)).toBe('2h overdue')
})
it('shows "Now" for very recent times', () => {
const now = new Date('2024-01-15T10:00:00+08:00')
expect(formatTimeUntil(new Date('2024-01-15T10:00:30+08:00'), now)).toBe('Now')
})
})
})

View File

@@ -0,0 +1,373 @@
import { toZonedTime, fromZonedTime } from 'date-fns-tz'
import {
addDays,
addHours,
addMinutes,
startOfDay,
setHours,
setMinutes,
isBefore,
isAfter,
differenceInMinutes,
getDay,
} from 'date-fns'
import type {
Medication,
DoseLog,
MedicationDueStatus,
ScheduleData,
FixedTimesSchedule,
IntervalSchedule,
WeekdaysSchedule,
PRNSchedule,
} from './types'
const TIMEZONE = process.env.TZ || 'Australia/Perth'
const OVERDUE_GRACE_MINUTES = 60
/**
* Parse a time string (HH:mm) into hours and minutes
*/
function parseTime(timeStr: string): { hours: number; minutes: number } {
const [hours, minutes] = timeStr.split(':').map(Number)
return { hours, minutes }
}
/**
* Set a date to a specific time of day in the configured timezone
*/
function setTimeInTimezone(date: Date, timeStr: string): Date {
const { hours, minutes } = parseTime(timeStr)
const zonedDate = toZonedTime(date, TIMEZONE)
const dayStart = startOfDay(zonedDate)
const withTime = setMinutes(setHours(dayStart, hours), minutes)
return fromZonedTime(withTime, TIMEZONE)
}
/**
* Get the last dose for a medication (excluding undone doses)
*/
function getLastDose(medicationId: string, doseLogs: DoseLog[]): DoseLog | null {
const validDoses = doseLogs
.filter((d) => d.medicationId === medicationId && !d.undoneAt)
.sort((a, b) => b.takenAt.getTime() - a.takenAt.getTime())
return validDoses[0] || null
}
/**
* Get doses for a medication on a specific day
*/
function getDosesOnDay(
medicationId: string,
day: Date,
doseLogs: DoseLog[]
): DoseLog[] {
const dayStart = startOfDay(toZonedTime(day, TIMEZONE))
const dayEnd = addDays(dayStart, 1)
return doseLogs.filter((d) => {
if (d.medicationId !== medicationId || d.undoneAt) return false
const doseZoned = toZonedTime(d.takenAt, TIMEZONE)
return doseZoned >= dayStart && doseZoned < dayEnd
})
}
/**
* Calculate next due time for FIXED_TIMES schedule
*/
function calculateFixedTimesDue(
med: Medication,
schedule: FixedTimesSchedule,
now: Date,
doseLogs: DoseLog[]
): Date | null {
const nowZoned = toZonedTime(now, TIMEZONE)
const todaysDoses = getDosesOnDay(med.id, now, doseLogs)
const takenTimes = new Set(
todaysDoses.map((d) => {
const z = toZonedTime(d.takenAt, TIMEZONE)
return `${z.getHours().toString().padStart(2, '0')}:${z.getMinutes().toString().padStart(2, '0')}`
})
)
// Sort times chronologically
const sortedTimes = [...schedule.times].sort()
// Find the next due time today that hasn't been taken
for (const time of sortedTimes) {
const dueTime = setTimeInTimezone(now, time)
if (!takenTimes.has(time) && isAfter(dueTime, addMinutes(now, -OVERDUE_GRACE_MINUTES))) {
return dueTime
}
}
// All today's doses taken or missed, return first time tomorrow
const tomorrow = addDays(now, 1)
return setTimeInTimezone(tomorrow, sortedTimes[0])
}
/**
* Calculate next due time for INTERVAL schedule
*/
function calculateIntervalDue(
med: Medication,
schedule: IntervalSchedule,
now: Date,
doseLogs: DoseLog[]
): Date | null {
const lastDose = getLastDose(med.id, doseLogs)
if (lastDose) {
// Next dose is interval hours after last dose
return addHours(lastDose.takenAt, schedule.hours)
}
// No doses yet - start from the startTime today or tomorrow
const startToday = setTimeInTimezone(now, schedule.startTime)
if (isAfter(startToday, now)) {
return startToday
}
// Calculate how many intervals have passed since start time today
const minutesSinceStart = differenceInMinutes(now, startToday)
const intervalMinutes = schedule.hours * 60
const intervalsPassed = Math.floor(minutesSinceStart / intervalMinutes)
const nextDue = addMinutes(startToday, (intervalsPassed + 1) * intervalMinutes)
return nextDue
}
/**
* Calculate next due time for WEEKDAYS schedule
*/
function calculateWeekdaysDue(
med: Medication,
schedule: WeekdaysSchedule,
now: Date,
doseLogs: DoseLog[]
): Date | null {
const nowZoned = toZonedTime(now, TIMEZONE)
const todayDayOfWeek = getDay(nowZoned) // 0 = Sunday
// Check if today is a scheduled day
if (schedule.days.includes(todayDayOfWeek)) {
const todaysDoses = getDosesOnDay(med.id, now, doseLogs)
if (todaysDoses.length === 0) {
const dueToday = setTimeInTimezone(now, schedule.time)
if (isAfter(dueToday, addMinutes(now, -OVERDUE_GRACE_MINUTES))) {
return dueToday
}
}
}
// Find the next scheduled day
for (let i = 1; i <= 7; i++) {
const checkDate = addDays(now, i)
const checkZoned = toZonedTime(checkDate, TIMEZONE)
const checkDayOfWeek = getDay(checkZoned)
if (schedule.days.includes(checkDayOfWeek)) {
return setTimeInTimezone(checkDate, schedule.time)
}
}
return null
}
/**
* Calculate PRN availability
*/
function calculatePRNStatus(
med: Medication,
schedule: PRNSchedule,
now: Date,
doseLogs: DoseLog[]
): { availableAt: Date | null; available: boolean; lastTakenAt: Date | null } {
const lastDose = getLastDose(med.id, doseLogs)
if (!lastDose) {
return { availableAt: null, available: true, lastTakenAt: null }
}
const availableAt = addHours(lastDose.takenAt, schedule.minHoursBetween)
const available = isBefore(availableAt, now)
return {
availableAt: available ? null : availableAt,
available,
lastTakenAt: lastDose.takenAt,
}
}
/**
* Check if medication is within its active date range
*/
function isMedicationActive(med: Medication, now: Date): boolean {
if (!med.active) return false
const nowZoned = toZonedTime(now, TIMEZONE)
const today = startOfDay(nowZoned)
if (med.startDate && isBefore(today, startOfDay(toZonedTime(med.startDate, TIMEZONE)))) {
return false
}
if (med.endDate && isAfter(today, startOfDay(toZonedTime(med.endDate, TIMEZONE)))) {
return false
}
return true
}
/**
* Calculate the due status for a single medication
*/
export function calculateMedicationDueStatus(
med: Medication,
now: Date,
doseLogs: DoseLog[]
): MedicationDueStatus {
const schedule = med.scheduleData as ScheduleData
const lastDose = getLastDose(med.id, doseLogs)
// Base status
const status: MedicationDueStatus = {
medication: med,
nextDueAt: null,
isOverdue: false,
overdueMinutes: 0,
isPRN: schedule.type === 'PRN',
prnAvailableAt: null,
prnAvailable: false,
lastTakenAt: lastDose?.takenAt || null,
}
// Check if medication is active
if (!isMedicationActive(med, now)) {
return status
}
// Calculate based on schedule type
switch (schedule.type) {
case 'FIXED_TIMES':
status.nextDueAt = calculateFixedTimesDue(med, schedule, now, doseLogs)
break
case 'INTERVAL':
status.nextDueAt = calculateIntervalDue(med, schedule, now, doseLogs)
break
case 'WEEKDAYS':
status.nextDueAt = calculateWeekdaysDue(med, schedule, now, doseLogs)
break
case 'PRN': {
const prnStatus = calculatePRNStatus(med, schedule, now, doseLogs)
status.prnAvailableAt = prnStatus.availableAt
status.prnAvailable = prnStatus.available
status.lastTakenAt = prnStatus.lastTakenAt
// PRN meds don't have a "due" time
return status
}
}
// Calculate overdue status
if (status.nextDueAt && isBefore(status.nextDueAt, now)) {
const overdue = differenceInMinutes(now, status.nextDueAt)
if (overdue > OVERDUE_GRACE_MINUTES) {
status.isOverdue = true
status.overdueMinutes = overdue
}
}
return status
}
/**
* Calculate due status for all medications and sort by priority
*/
export function calculateAllMedicationsDue(
medications: Medication[],
now: Date,
doseLogs: DoseLog[]
): MedicationDueStatus[] {
const statuses = medications.map((med) =>
calculateMedicationDueStatus(med, now, doseLogs)
)
// Sort by priority:
// 1. Overdue (most overdue first)
// 2. Due now or soon (by due time)
// 3. PRN available
// 4. PRN in cooldown
// 5. Future doses
return statuses.sort((a, b) => {
// Overdue first, sorted by how overdue
if (a.isOverdue && !b.isOverdue) return -1
if (!a.isOverdue && b.isOverdue) return 1
if (a.isOverdue && b.isOverdue) {
return b.overdueMinutes - a.overdueMinutes
}
// Non-PRN meds with due times
if (!a.isPRN && !b.isPRN && a.nextDueAt && b.nextDueAt) {
return a.nextDueAt.getTime() - b.nextDueAt.getTime()
}
// PRN available before PRN in cooldown
if (a.isPRN && b.isPRN) {
if (a.prnAvailable && !b.prnAvailable) return -1
if (!a.prnAvailable && b.prnAvailable) return 1
}
// Non-PRN before PRN
if (!a.isPRN && b.isPRN) return -1
if (a.isPRN && !b.isPRN) return 1
return 0
})
}
/**
* Get medications that are due within the next X minutes
*/
export function getMedicationsDueSoon(
medications: Medication[],
now: Date,
doseLogs: DoseLog[],
withinMinutes: number = 60
): MedicationDueStatus[] {
const allStatuses = calculateAllMedicationsDue(medications, now, doseLogs)
const cutoff = addMinutes(now, withinMinutes)
return allStatuses.filter((status) => {
if (status.isOverdue) return true
if (status.isPRN && status.prnAvailable) return true
if (status.nextDueAt && isBefore(status.nextDueAt, cutoff)) return true
return false
})
}
/**
* Format time remaining until a date
*/
export function formatTimeUntil(targetDate: Date, now: Date): string {
const minutes = differenceInMinutes(targetDate, now)
if (minutes < 0) {
const overdue = Math.abs(minutes)
if (overdue < 60) return `${overdue}m overdue`
const hours = Math.floor(overdue / 60)
const mins = overdue % 60
return mins > 0 ? `${hours}h ${mins}m overdue` : `${hours}h overdue`
}
if (minutes < 1) return 'Now'
if (minutes < 60) return `in ${minutes}m`
const hours = Math.floor(minutes / 60)
const mins = minutes % 60
return mins > 0 ? `in ${hours}h ${mins}m` : `in ${hours}h`
}

18
src/lib/schedule/index.ts Normal file
View File

@@ -0,0 +1,18 @@
export type {
ScheduleType,
ScheduleData,
FixedTimesSchedule,
IntervalSchedule,
WeekdaysSchedule,
PRNSchedule,
Medication,
DoseLog,
MedicationDueStatus,
} from './types'
export {
calculateMedicationDueStatus,
calculateAllMedicationsDue,
getMedicationsDueSoon,
formatTimeUntil,
} from './calculator'

58
src/lib/schedule/types.ts Normal file
View File

@@ -0,0 +1,58 @@
export type ScheduleType = 'FIXED_TIMES' | 'INTERVAL' | 'WEEKDAYS' | 'PRN'
export interface FixedTimesSchedule {
type: 'FIXED_TIMES'
times: string[] // HH:mm format
}
export interface IntervalSchedule {
type: 'INTERVAL'
hours: number
startTime: string // HH:mm format - when to start counting from each day
}
export interface WeekdaysSchedule {
type: 'WEEKDAYS'
days: number[] // 0-6 (Sunday-Saturday)
time: string // HH:mm format
}
export interface PRNSchedule {
type: 'PRN'
minHoursBetween: number
}
export type ScheduleData =
| FixedTimesSchedule
| IntervalSchedule
| WeekdaysSchedule
| PRNSchedule
export interface Medication {
id: string
name: string
instructions: string | null
scheduleType: ScheduleType
scheduleData: ScheduleData
startDate: Date | null
endDate: Date | null
active: boolean
}
export interface DoseLog {
id: string
medicationId: string
takenAt: Date
undoneAt: Date | null
}
export interface MedicationDueStatus {
medication: Medication
nextDueAt: Date | null
isOverdue: boolean
overdueMinutes: number
isPRN: boolean
prnAvailableAt: Date | null // For PRN meds, when it becomes available
prnAvailable: boolean
lastTakenAt: Date | null
}

124
src/lib/sync/db.ts Normal file
View File

@@ -0,0 +1,124 @@
import Dexie, { type Table } from 'dexie'
export interface LocalAppointment {
id: string
workspaceId: string
title: string
datetime: string
location: string | null
mapUrl: string | null
notes: string | null
deletedAt: string | null
version: number
syncedAt: string
createdBy?: { id: string; name: string }
updatedBy?: { id: string; name: string }
}
export interface LocalMedication {
id: string
workspaceId: string
name: string
instructions: string | null
scheduleType: string
scheduleData: Record<string, unknown>
startDate: string | null
endDate: string | null
active: boolean
deletedAt: string | null
version: number
syncedAt: string
createdBy?: { id: string; name: string }
updatedBy?: { id: string; name: string }
}
export interface LocalNote {
id: string
workspaceId: string
type: 'QUESTION' | 'GENERAL'
content: string
askedAt: string | null
deletedAt: string | null
version: number
syncedAt: string
createdBy?: { id: string; name: string }
updatedBy?: { id: string; name: string }
}
export interface LocalDoseLog {
id: string
medicationId: string
workspaceId: string
takenAt: string
undoneAt: string | null
syncedAt: string
medication?: { id: string; name: string }
loggedBy?: { id: string; name: string }
undoneBy?: { id: string; name: string } | null
}
export interface LocalWorkspace {
id: string
name: string
clinicPhone: string | null
emergencyPhone: string | null
quietHoursStart: string | null
quietHoursEnd: string | null
largeTextMode: boolean
role?: string
updatedAt: string
}
export interface SyncOp {
id: string
workspaceId: string
type: 'CREATE' | 'UPDATE' | 'DELETE' | 'TAKE_DOSE' | 'UNDO_DOSE' | 'MARK_ASKED'
entityType: 'APPOINTMENT' | 'MEDICATION' | 'NOTE' | 'DOSE_LOG'
entityId?: string
data?: Record<string, unknown>
timestamp: number
retries: number
}
export interface SyncMeta {
id: string
workspaceId: string
cursor: number
lastSyncAt: number
}
class NextStepDB extends Dexie {
appointments!: Table<LocalAppointment, string>
medications!: Table<LocalMedication, string>
notes!: Table<LocalNote, string>
doseLogs!: Table<LocalDoseLog, string>
workspaces!: Table<LocalWorkspace, string>
outbox!: Table<SyncOp, string>
syncMeta!: Table<SyncMeta, string>
constructor() {
super('NextStepDB')
this.version(1).stores({
appointments: 'id, workspaceId, datetime, deletedAt',
medications: 'id, workspaceId, active, deletedAt',
notes: 'id, workspaceId, type, deletedAt',
doseLogs: 'id, medicationId, workspaceId, takenAt',
workspaces: 'id',
outbox: 'id, workspaceId, timestamp',
syncMeta: 'id, workspaceId',
})
}
}
export const db = new NextStepDB()
// Helper to generate temporary IDs for offline-created items
export function generateTempId(): string {
return `temp_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`
}
// Helper to check if an ID is temporary
export function isTempId(id: string): boolean {
return id.startsWith('temp_')
}

28
src/lib/sync/index.ts Normal file
View File

@@ -0,0 +1,28 @@
export { db, generateTempId, isTempId } from './db'
export type {
LocalAppointment,
LocalMedication,
LocalNote,
LocalDoseLog,
LocalWorkspace,
SyncOp,
SyncMeta,
} from './db'
export {
getSyncState,
subscribeSyncState,
sync,
pullChanges,
pushChanges,
startAutoSync,
stopAutoSync,
addToOutbox,
createLocalAppointment,
updateLocalAppointment,
deleteLocalAppointment,
createLocalNote,
logDose,
undoDose,
markQuestionAsked,
} from './manager'

445
src/lib/sync/manager.ts Normal file
View File

@@ -0,0 +1,445 @@
import { db, generateTempId, type SyncOp } from './db'
import type { LocalAppointment, LocalMedication, LocalNote, LocalDoseLog } from './db'
const SYNC_INTERVAL = 30000 // 30 seconds
const MAX_RETRIES = 3
interface SyncState {
isSyncing: boolean
lastSyncAt: number | null
error: string | null
hasConflict: boolean
}
let syncState: SyncState = {
isSyncing: false,
lastSyncAt: null,
error: null,
hasConflict: false,
}
let syncInterval: ReturnType<typeof setInterval> | null = null
let listeners: ((state: SyncState) => void)[] = []
export function getSyncState(): SyncState {
return { ...syncState }
}
export function subscribeSyncState(listener: (state: SyncState) => void): () => void {
listeners.push(listener)
return () => {
listeners = listeners.filter((l) => l !== listener)
}
}
function notifyListeners() {
listeners.forEach((l) => l({ ...syncState }))
}
async function fetchWithAuth(url: string, options: RequestInit = {}) {
const response = await fetch(url, {
...options,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...options.headers,
},
})
if (response.status === 401) {
// Redirect to login
window.location.href = '/login'
throw new Error('Unauthorized')
}
return response
}
export async function pullChanges(workspaceId: string): Promise<boolean> {
try {
// Get current cursor
const meta = await db.syncMeta.get(workspaceId)
const cursor = meta?.cursor || 0
const response = await fetchWithAuth(`/api/sync?workspaceId=${workspaceId}&since=${cursor}`)
if (!response.ok) {
throw new Error('Sync fetch failed')
}
const data = await response.json()
// Update local database
await db.transaction('rw', [db.appointments, db.medications, db.notes, db.doseLogs, db.workspaces, db.syncMeta], async () => {
// Update workspace
if (data.workspace) {
await db.workspaces.put({
...data.workspace,
updatedAt: data.workspace.updatedAt || new Date().toISOString(),
})
}
// Update appointments
for (const appt of data.appointments || []) {
const existing = await db.appointments.get(appt.id)
// Last-write-wins: server is authoritative
if (!existing || new Date(appt.syncedAt) > new Date(existing.syncedAt)) {
await db.appointments.put({
...appt,
datetime: appt.datetime,
syncedAt: appt.syncedAt,
})
}
}
// Update medications
for (const med of data.medications || []) {
const existing = await db.medications.get(med.id)
if (!existing || new Date(med.syncedAt) > new Date(existing.syncedAt)) {
await db.medications.put({
...med,
syncedAt: med.syncedAt,
})
}
}
// Update notes
for (const note of data.notes || []) {
const existing = await db.notes.get(note.id)
if (!existing || new Date(note.syncedAt) > new Date(existing.syncedAt)) {
await db.notes.put({
...note,
syncedAt: note.syncedAt,
})
}
}
// Append dose logs (append-only, never overwrite)
for (const dose of data.doseLogs || []) {
const existing = await db.doseLogs.get(dose.id)
if (!existing) {
await db.doseLogs.add({
...dose,
takenAt: dose.takenAt,
syncedAt: dose.syncedAt,
})
} else if (dose.undoneAt && !existing.undoneAt) {
// Update undo status
await db.doseLogs.update(dose.id, {
undoneAt: dose.undoneAt,
undoneBy: dose.undoneBy,
})
}
}
// Update sync cursor
await db.syncMeta.put({
id: workspaceId,
workspaceId,
cursor: data.cursor,
lastSyncAt: Date.now(),
})
})
syncState.hasConflict = data.hasConflicts
return true
} catch (error) {
console.error('Pull changes error:', error)
return false
}
}
export async function pushChanges(workspaceId: string): Promise<boolean> {
try {
const ops = await db.outbox.where('workspaceId').equals(workspaceId).toArray()
if (ops.length === 0) {
return true
}
const response = await fetchWithAuth('/api/sync', {
method: 'POST',
body: JSON.stringify({
workspaceId,
ops: ops.map((op) => ({
id: op.id,
type: op.type,
entityType: op.entityType,
entityId: op.entityId,
data: op.data,
timestamp: op.timestamp,
})),
}),
})
if (!response.ok) {
throw new Error('Sync push failed')
}
const data = await response.json()
// Process results and remove successful ops from outbox
await db.transaction('rw', [db.outbox, db.appointments, db.notes], async () => {
for (const result of data.results) {
if (result.success) {
// Find the op
const op = ops.find((o) => o.id === result.opId)
if (op && op.entityId?.startsWith('temp_') && result.entityId) {
// Update local entity with real ID
if (op.entityType === 'APPOINTMENT') {
const local = await db.appointments.get(op.entityId)
if (local) {
await db.appointments.delete(op.entityId)
await db.appointments.put({ ...local, id: result.entityId })
}
} else if (op.entityType === 'NOTE') {
const local = await db.notes.get(op.entityId)
if (local) {
await db.notes.delete(op.entityId)
await db.notes.put({ ...local, id: result.entityId })
}
}
}
// Remove from outbox
await db.outbox.delete(result.opId)
} else {
// Increment retry count or remove if max retries
const op = await db.outbox.get(result.opId)
if (op) {
if (op.retries >= MAX_RETRIES) {
await db.outbox.delete(result.opId)
} else {
await db.outbox.update(result.opId, { retries: op.retries + 1 })
}
}
}
}
})
return true
} catch (error) {
console.error('Push changes error:', error)
return false
}
}
export async function sync(workspaceId: string): Promise<void> {
if (syncState.isSyncing) {
return
}
syncState.isSyncing = true
syncState.error = null
notifyListeners()
try {
// Push first, then pull
const pushSuccess = await pushChanges(workspaceId)
const pullSuccess = await pullChanges(workspaceId)
if (pushSuccess && pullSuccess) {
syncState.lastSyncAt = Date.now()
syncState.error = null
} else {
syncState.error = 'Sync partially failed'
}
} catch (error) {
syncState.error = error instanceof Error ? error.message : 'Sync failed'
} finally {
syncState.isSyncing = false
notifyListeners()
}
}
export function startAutoSync(workspaceId: string) {
if (syncInterval) {
clearInterval(syncInterval)
}
// Initial sync
sync(workspaceId)
// Set up interval
syncInterval = setInterval(() => {
if (navigator.onLine) {
sync(workspaceId)
}
}, SYNC_INTERVAL)
// Sync when coming back online
window.addEventListener('online', () => sync(workspaceId))
}
export function stopAutoSync() {
if (syncInterval) {
clearInterval(syncInterval)
syncInterval = null
}
}
// Helper functions for adding to outbox
export async function addToOutbox(op: Omit<SyncOp, 'id' | 'retries'>): Promise<void> {
await db.outbox.add({
...op,
id: generateTempId(),
retries: 0,
})
}
// Convenience functions for local operations + outbox
export async function createLocalAppointment(
workspaceId: string,
data: Omit<LocalAppointment, 'id' | 'workspaceId' | 'version' | 'syncedAt' | 'deletedAt'>
): Promise<LocalAppointment> {
const id = generateTempId()
const appointment: LocalAppointment = {
...data,
id,
workspaceId,
version: 1,
syncedAt: new Date().toISOString(),
deletedAt: null,
}
await db.appointments.add(appointment)
await addToOutbox({
workspaceId,
type: 'CREATE',
entityType: 'APPOINTMENT',
entityId: id,
data: {
title: data.title,
datetime: data.datetime,
location: data.location,
mapUrl: data.mapUrl,
notes: data.notes,
},
timestamp: Date.now(),
})
return appointment
}
export async function updateLocalAppointment(
appointment: LocalAppointment,
updates: Partial<Pick<LocalAppointment, 'title' | 'datetime' | 'location' | 'mapUrl' | 'notes'>>
): Promise<void> {
await db.appointments.update(appointment.id, {
...updates,
version: appointment.version + 1,
syncedAt: new Date().toISOString(),
})
await addToOutbox({
workspaceId: appointment.workspaceId,
type: 'UPDATE',
entityType: 'APPOINTMENT',
entityId: appointment.id,
data: updates,
timestamp: Date.now(),
})
}
export async function deleteLocalAppointment(appointment: LocalAppointment): Promise<void> {
await db.appointments.update(appointment.id, {
deletedAt: new Date().toISOString(),
version: appointment.version + 1,
syncedAt: new Date().toISOString(),
})
await addToOutbox({
workspaceId: appointment.workspaceId,
type: 'DELETE',
entityType: 'APPOINTMENT',
entityId: appointment.id,
timestamp: Date.now(),
})
}
export async function createLocalNote(
workspaceId: string,
data: { type: 'QUESTION' | 'GENERAL'; content: string }
): Promise<LocalNote> {
const id = generateTempId()
const note: LocalNote = {
id,
workspaceId,
type: data.type,
content: data.content,
askedAt: null,
deletedAt: null,
version: 1,
syncedAt: new Date().toISOString(),
}
await db.notes.add(note)
await addToOutbox({
workspaceId,
type: 'CREATE',
entityType: 'NOTE',
entityId: id,
data,
timestamp: Date.now(),
})
return note
}
export async function logDose(
workspaceId: string,
medicationId: string,
medication: { id: string; name: string }
): Promise<LocalDoseLog> {
const id = generateTempId()
const now = new Date().toISOString()
const doseLog: LocalDoseLog = {
id,
medicationId,
workspaceId,
takenAt: now,
undoneAt: null,
syncedAt: now,
medication,
}
await db.doseLogs.add(doseLog)
await addToOutbox({
workspaceId,
type: 'TAKE_DOSE',
entityType: 'DOSE_LOG',
entityId: id,
data: { medicationId, takenAt: now },
timestamp: Date.now(),
})
return doseLog
}
export async function undoDose(doseLog: LocalDoseLog): Promise<void> {
const now = new Date().toISOString()
await db.doseLogs.update(doseLog.id, { undoneAt: now })
await addToOutbox({
workspaceId: doseLog.workspaceId,
type: 'UNDO_DOSE',
entityType: 'DOSE_LOG',
entityId: doseLog.id,
timestamp: Date.now(),
})
}
export async function markQuestionAsked(note: LocalNote): Promise<void> {
const now = new Date().toISOString()
await db.notes.update(note.id, { askedAt: now })
await addToOutbox({
workspaceId: note.workspaceId,
type: 'MARK_ASKED',
entityType: 'NOTE',
entityId: note.id,
timestamp: Date.now(),
})
}

View File

@@ -0,0 +1 @@
export * from './schemas'

View File

@@ -0,0 +1,134 @@
import { z } from 'zod'
// Auth schemas
export const loginSchema = z.object({
email: z.string().email('Please enter a valid email'),
password: z.string().min(8, 'Password must be at least 8 characters'),
})
export const registerSchema = z.object({
email: z.string().email('Please enter a valid email'),
password: z.string().min(8, 'Password must be at least 8 characters'),
name: z.string().min(1, 'Name is required').max(100, 'Name is too long'),
})
// Workspace schemas
export const createWorkspaceSchema = z.object({
name: z.string().min(1, 'Name is required').max(100, 'Name is too long'),
})
export const updateWorkspaceSchema = z.object({
name: z.string().min(1).max(100).optional(),
clinicPhone: z.string().max(50).nullable().optional(),
emergencyPhone: z.string().max(50).nullable().optional(),
quietHoursStart: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).nullable().optional(),
quietHoursEnd: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).nullable().optional(),
largeTextMode: z.boolean().optional(),
})
export const inviteSchema = z.object({
role: z.enum(['EDITOR', 'VIEWER']),
expiresInDays: z.number().min(1).max(30).default(7),
})
// Appointment schemas
export const appointmentSchema = z.object({
title: z.string().min(1, 'Title is required').max(200),
datetime: z.string().datetime(),
location: z.string().max(500).nullable().optional(),
mapUrl: z.string().url().nullable().optional(),
notes: z.string().max(2000).nullable().optional(),
})
// Medication schedule schemas
const fixedTimesScheduleSchema = z.object({
type: z.literal('FIXED_TIMES'),
times: z.array(z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/)).min(1).max(24),
})
const intervalScheduleSchema = z.object({
type: z.literal('INTERVAL'),
hours: z.number().min(1).max(72),
startTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/),
})
const weekdaysScheduleSchema = z.object({
type: z.literal('WEEKDAYS'),
days: z.array(z.number().min(0).max(6)).min(1).max(7),
time: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/),
})
const prnScheduleSchema = z.object({
type: z.literal('PRN'),
minHoursBetween: z.number().min(0.5).max(72),
})
export const scheduleDataSchema = z.discriminatedUnion('type', [
fixedTimesScheduleSchema,
intervalScheduleSchema,
weekdaysScheduleSchema,
prnScheduleSchema,
])
export const medicationSchema = z.object({
name: z.string().min(1, 'Name is required').max(200),
instructions: z.string().max(1000).nullable().optional(),
scheduleType: z.enum(['FIXED_TIMES', 'INTERVAL', 'WEEKDAYS', 'PRN']),
scheduleData: scheduleDataSchema,
startDate: z.string().datetime().nullable().optional(),
endDate: z.string().datetime().nullable().optional(),
active: z.boolean().default(true),
})
// Dose log schemas
export const doseLogSchema = z.object({
medicationId: z.string().cuid(),
takenAt: z.string().datetime().optional(), // Defaults to now
})
export const undoDoseSchema = z.object({
doseLogId: z.string().cuid(),
})
// Note schemas
export const noteSchema = z.object({
type: z.enum(['QUESTION', 'GENERAL']),
content: z.string().min(1, 'Content is required').max(5000),
})
export const markQuestionAskedSchema = z.object({
noteId: z.string().cuid(),
})
// Sync schemas
export const syncQuerySchema = z.object({
workspaceId: z.string().cuid(),
since: z.coerce.number().optional(),
})
export const syncOpSchema = z.object({
id: z.string(),
type: z.enum(['CREATE', 'UPDATE', 'DELETE', 'TAKE_DOSE', 'UNDO_DOSE', 'MARK_ASKED']),
entityType: z.enum(['APPOINTMENT', 'MEDICATION', 'NOTE', 'DOSE_LOG']),
entityId: z.string().optional(),
data: z.record(z.unknown()).optional(),
timestamp: z.number(),
})
export const syncOpsSchema = z.object({
workspaceId: z.string().cuid(),
ops: z.array(syncOpSchema),
})
// Type exports
export type LoginInput = z.infer<typeof loginSchema>
export type RegisterInput = z.infer<typeof registerSchema>
export type CreateWorkspaceInput = z.infer<typeof createWorkspaceSchema>
export type UpdateWorkspaceInput = z.infer<typeof updateWorkspaceSchema>
export type InviteInput = z.infer<typeof inviteSchema>
export type AppointmentInput = z.infer<typeof appointmentSchema>
export type MedicationInput = z.infer<typeof medicationSchema>
export type ScheduleDataInput = z.infer<typeof scheduleDataSchema>
export type DoseLogInput = z.infer<typeof doseLogSchema>
export type NoteInput = z.infer<typeof noteSchema>
export type SyncOp = z.infer<typeof syncOpSchema>

88
tailwind.config.ts Normal file
View File

@@ -0,0 +1,88 @@
import type { Config } from 'tailwindcss'
const config: Config = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
// Calm, healing palette
primary: {
50: '#f0f9f4',
100: '#dcf1e4',
200: '#bbe3cc',
300: '#8dcda8',
400: '#5bb17f',
500: '#3a9563',
600: '#2a784e',
700: '#235f40',
800: '#1f4c35',
900: '#1b3f2d',
950: '#0d2319',
},
secondary: {
50: '#f5f7fa',
100: '#ebeef3',
200: '#d2dae5',
300: '#aab9ce',
400: '#7c93b3',
500: '#5c769a',
600: '#485e80',
700: '#3b4d68',
800: '#344257',
900: '#2f3a4a',
950: '#1f2631',
},
accent: {
50: '#fef6ee',
100: '#fdebd7',
200: '#fad3ae',
300: '#f6b37b',
400: '#f18946',
500: '#ed6b22',
600: '#de5118',
700: '#b83c16',
800: '#93311a',
900: '#772b18',
950: '#40130b',
},
background: '#fafbfc',
surface: '#ffffff',
muted: '#f1f5f9',
border: '#e2e8f0',
},
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
},
fontSize: {
// Large text mode sizes
'lg-base': '1.125rem',
'lg-lg': '1.25rem',
'lg-xl': '1.5rem',
'lg-2xl': '1.875rem',
'lg-3xl': '2.25rem',
},
spacing: {
// Touch-friendly spacing
'touch': '44px',
'touch-lg': '56px',
},
borderRadius: {
'card': '16px',
'button': '12px',
},
boxShadow: {
'card': '0 1px 3px 0 rgb(0 0 0 / 0.04), 0 1px 2px -1px rgb(0 0 0 / 0.04)',
'card-hover': '0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05)',
'button': '0 1px 2px 0 rgb(0 0 0 / 0.05)',
},
},
},
plugins: [
require('@tailwindcss/forms'),
],
}
export default config

26
tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

15
vitest.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vitest/config'
import path from 'path'
export default defineConfig({
test: {
environment: 'node',
globals: true,
include: ['**/*.test.ts'],
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})