Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Commands

```bash
bun dev # Start development server (http://localhost:3000)
bun build # Production build
bun start # Start production server
bun install # Install dependencies
```

No test suite is configured. Manual testing is expected.

### Docker

```bash
docker compose up --build # Build and run with Docker
```

Requires env vars: `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`

### Local Supabase (optional)

```bash
supabase start # Requires Docker — runs local Postgres + Auth
```

## Environment Setup

Copy `.env.local.example` to `.env.local` and fill in Supabase credentials:

```
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
```

## Architecture

**Stack:** Next.js 15 (App Router) + Bun + Supabase (PostgreSQL) + TypeScript + Tailwind CSS 4

**UI libraries:** shadcn/ui (primary), Radix UI, Ant Design, Framer Motion, Recharts, Sonner

**Data fetching:** SWR for client-side caching; server actions and API routes for mutations

### Directory layout

```
app/
(admin)/ Admin dashboard routes
(login)/ Auth/login routes
(main)/ Main app routes
_actions/ Server actions (Next.js server-side mutations)
patient/ Patient detail and management pages
patient-create/
api/ REST API routes
v1/patients/
admin/
auth/
forms/
service/ Business logic — Supabase query functions called by server actions and API routes

components/
ui/ shadcn/ui primitives
patient/ Patient-specific components
admin/ Admin-specific components
question-types/ Form question type renderers

lib/
timezone.ts Bangkok (GMT+7) timezone utilities — important for all date operations
question-types.ts Form field type definitions

utils/supabase/
client.ts Browser-side Supabase client
server.ts Server-side Supabase client (uses cookies)
middleware.ts Auth session refresh middleware

supabase/
migrations/ Versioned SQL migrations (format: YYYYMMDD*)
functions/ Supabase Edge Functions (auto-assign-groups, manage-groups, export-data)
```

### Data layer

No ORM — the app uses the Supabase JS SDK directly. Query logic lives in `app/service/`. Key tables: `patients`, `patient_groups`, `forms`, and form submission tables.

Row Level Security (RLS) is enabled. Auth flows through Supabase Auth with SSR session handling via `@supabase/ssr`.

### Timezone

All dates must be handled in **Bangkok time (Asia/Bangkok, GMT+7)**. See `lib/timezone.ts` for helpers. This was a known production issue on Vercel — see `TIMEZONE_FIX.md` for context.

### Docker runtime config

The multi-stage Dockerfile uses Next.js standalone output. `docker-entrypoint.sh` injects runtime environment variables by replacing placeholders in the built JS files — this enables environment configuration at container start without rebuilding.
101 changes: 1 addition & 100 deletions app/(main)/patient/[id]/[formId]/[questionId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { CalendarDays } from 'lucide-react';
import { getFormById, getQuestionsByFormId } from '@/app/service/patient-client';
import { Form, Question } from '@/app/service/patient-client';
import { createClient } from '@/utils/supabase/client';
import { calculateTotalScore } from '@/lib/scoring';

export default function QuestionPage() {
const params = useParams();
Expand Down Expand Up @@ -118,106 +119,6 @@ export default function QuestionPage() {
}
};

const calculateTotalScore = (answers: Record<number, string>, questions: any[]) => {
let totalScore = 0;

console.log('🔍 Starting score calculation...');
console.log('📝 All answers:', answers);
console.log('🔢 Number of answers:', Object.keys(answers).length);
console.log('❓ All questions:', questions.map(q => ({ id: q.question_id, type: q.question_type })));
console.log('🔢 Number of questions:', questions.length);

// Check if we have answers for all questions
const questionIds = questions.map(q => q.question_id);
const answeredIds = Object.keys(answers).map(id => parseInt(id));
const missingAnswers = questionIds.filter(id => !answeredIds.includes(id));

if (missingAnswers.length > 0) {
console.warn('⚠️ Missing answers for questions:', missingAnswers);
}

Object.entries(answers).forEach(([questionIdStr, answer]) => {
const questionId = parseInt(questionIdStr, 10);
const question = questions.find(q => q.question_id === questionId);

if (!question) {
console.log(`⚠️ Question not found for ID: ${questionId}`);
return;
}

let questionScore = 0;
console.log(`\n🔍 Processing question ${questionId}:`);
console.log(` Type: ${question.question_type}`);
console.log(` Answer: "${answer}"`);
console.log(` Options:`, question.options);

switch (question.question_type) {
case 'multiple_choice':
case 'multipleChoice':
const choices = question.options?.choices || [];
console.log(` Choices available:`, choices);

const selectedChoice = choices.find((choice: any) => {
const choiceText = typeof choice === 'string' ? choice : (choice.text || choice.choice);
return choiceText === answer;
});

if (selectedChoice) {
questionScore = typeof selectedChoice === 'string' ? 0 : (parseFloat(selectedChoice.score) || 0);
console.log(` ✅ Found matching choice, score: ${questionScore}`);
} else {
console.log(` ⚠️ No matching choice found for answer: "${answer}"`);
}
break;

case 'true_false':
case 'trueFalse':
const options = question.options || {};
if (answer === 'true' && options.trueScore !== undefined) {
questionScore = parseFloat(options.trueScore) || 0;
} else if (answer === 'false' && options.falseScore !== undefined) {
questionScore = parseFloat(options.falseScore) || 0;
}
console.log(` ✅ True/False score: ${questionScore}`);
break;

case 'rating':
const ratingValue = parseFloat(answer || '0');
const multiplier = Number(question.options?.scoreMultiplier) || 1;
// Calculate exact score with precision, no rounding to integer
questionScore = parseFloat((ratingValue * multiplier).toFixed(2));
console.log(` ✅ Rating score: ${questionScore} (${ratingValue} × ${multiplier})`);
break;

case 'number':
const numberValue = parseFloat(answer || '0');
const numberMultiplier = Number(question.options?.scoreMultiplier) || 1;
// Calculate exact score with precision, no rounding to integer
questionScore = parseFloat((numberValue * numberMultiplier).toFixed(2));
console.log(` ✅ Number score: ${questionScore} (${numberValue} × ${numberMultiplier})`);
break;

case 'text':
questionScore = 0;
console.log(` ✅ Text question score: ${questionScore}`);
break;

default:
questionScore = 0;
console.log(` ⚠️ Unknown question type: ${question.question_type}`);
}

console.log(` 📊 Question ${questionId} contributes: ${questionScore} points`);
console.log(` 📈 Total before adding: ${totalScore}`);
totalScore += questionScore;
console.log(` 📈 Total after adding: ${totalScore}`);
});

console.log(`\n🎯 FINAL TOTAL SCORE: ${totalScore}`);
console.log(`📊 Summary: Processed ${Object.keys(answers).length} answers out of ${questions.length} questions`);
return totalScore;
};

const handleComplete = async () => {
setIsSaving(true);
console.log('🚀 Starting form submission process...');
Expand Down
Loading
Loading