AI Newsletter Digest improvements: fixed QP soft line break decoding, URL extraction, and content cleaning
This commit is contained in:
62
nextstep-features/treatment-milestones/IMPLEMENTATION.md
Normal file
62
nextstep-features/treatment-milestones/IMPLEMENTATION.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Treatment Milestone Tracker Implementation
|
||||
|
||||
## Database Schema Changes
|
||||
|
||||
Add to prisma/schema.prisma:
|
||||
|
||||
```prisma
|
||||
model TreatmentPlan {
|
||||
id String @id @default(cuid())
|
||||
workspaceId String @unique
|
||||
title String // e.g., "Grace's Chemotherapy Plan"
|
||||
totalCycles Int
|
||||
currentCycle Int @default(0)
|
||||
startDate DateTime?
|
||||
estimatedEnd DateTime?
|
||||
status String @default("ACTIVE") // ACTIVE, PAUSED, COMPLETED
|
||||
cycleType String @default("WEEKLY") // WEEKLY, BIWEEKLY, MONTHLY, CUSTOM
|
||||
cycleDays Int @default(7) // Days between cycles
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdById String
|
||||
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
createdBy User @relation(fields: [createdById], references: [id])
|
||||
milestones TreatmentMilestone[]
|
||||
}
|
||||
|
||||
model TreatmentMilestone {
|
||||
id String @id @default(cuid())
|
||||
planId String
|
||||
cycleNumber Int // Which cycle this milestone represents
|
||||
date DateTime // When it happened (or estimated)
|
||||
status String @default("UPCOMING") // UPCOMING, COMPLETED, SKIPPED
|
||||
notes String? // Personal reflection
|
||||
sideEffects String? // What was experienced
|
||||
celebratedAt DateTime? // When we showed the celebration
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
plan TreatmentPlan @relation(fields: [planId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
```
|
||||
|
||||
## API Routes
|
||||
|
||||
### GET /api/workspaces/[id]/treatment-plan
|
||||
Get the treatment plan for a workspace
|
||||
|
||||
### POST /api/workspaces/[id]/treatment-plan
|
||||
Create or update treatment plan
|
||||
|
||||
### POST /api/workspaces/[id]/treatment-plan/milestones/[cycleNumber]/complete
|
||||
Mark a milestone as completed
|
||||
|
||||
### GET /api/workspaces/[id]/treatment-plan/progress
|
||||
Get progress stats
|
||||
|
||||
## Components
|
||||
|
||||
- TreatmentProgress - Main progress widget
|
||||
- MilestoneCelebration - Celebration modal
|
||||
- TreatmentCalendar - Timeline view
|
||||
- CycleDetailView - Individual cycle details
|
||||
@@ -0,0 +1,111 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db/prisma'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { canAccessWorkspace } from '@/lib/db/workspace-access'
|
||||
|
||||
// POST /api/workspaces/[id]/treatment-plan/complete-cycle
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const workspaceId = params.id
|
||||
const access = await canAccessWorkspace(user.id, workspaceId)
|
||||
if (!access || access.role === 'VIEWER') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Get current plan
|
||||
const plan = await prisma.treatmentPlan.findUnique({
|
||||
where: { workspaceId },
|
||||
include: { milestones: true }
|
||||
})
|
||||
|
||||
if (!plan) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No treatment plan found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (plan.status !== 'ACTIVE') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Treatment plan is not active' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (plan.currentCycle >= plan.totalCycles) {
|
||||
return NextResponse.json(
|
||||
{ error: 'All cycles already completed' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const nextCycle = plan.currentCycle + 1
|
||||
|
||||
// Update milestone
|
||||
const milestone = plan.milestones.find(m => m.cycleNumber === nextCycle)
|
||||
if (milestone) {
|
||||
await prisma.treatmentMilestone.update({
|
||||
where: { id: milestone.id },
|
||||
data: {
|
||||
status: 'COMPLETED',
|
||||
date: new Date()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Update plan
|
||||
const isComplete = nextCycle >= plan.totalCycles
|
||||
const updatedPlan = await prisma.treatmentPlan.update({
|
||||
where: { workspaceId },
|
||||
data: {
|
||||
currentCycle: nextCycle,
|
||||
status: isComplete ? 'COMPLETED' : 'ACTIVE'
|
||||
},
|
||||
include: {
|
||||
milestones: {
|
||||
orderBy: { cycleNumber: 'asc' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Log audit
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
userId: user.id,
|
||||
action: 'COMPLETE_CYCLE',
|
||||
entityType: 'TREATMENT_PLAN',
|
||||
entityId: plan.id,
|
||||
details: {
|
||||
cycleCompleted: nextCycle,
|
||||
totalCycles: plan.totalCycles,
|
||||
isComplete
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
plan: updatedPlan,
|
||||
milestone: milestone,
|
||||
celebration: {
|
||||
cycleNumber: nextCycle,
|
||||
isHalfway: nextCycle === Math.floor(plan.totalCycles / 2),
|
||||
isFinal: isComplete
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to complete cycle:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to complete cycle' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db/prisma'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { canAccessWorkspace } from '@/lib/db/workspace-access'
|
||||
import { addDays } from 'date-fns'
|
||||
|
||||
// GET /api/workspaces/[id]/treatment-plan
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const workspaceId = params.id
|
||||
const access = await canAccessWorkspace(user.id, workspaceId)
|
||||
if (!access) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const plan = await prisma.treatmentPlan.findUnique({
|
||||
where: { workspaceId },
|
||||
include: {
|
||||
milestones: {
|
||||
orderBy: { cycleNumber: 'asc' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!plan) {
|
||||
return NextResponse.json({ plan: null })
|
||||
}
|
||||
|
||||
return NextResponse.json({ plan })
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch treatment plan:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch treatment plan' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/workspaces/[id]/treatment-plan
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const workspaceId = params.id
|
||||
const access = await canAccessWorkspace(user.id, workspaceId)
|
||||
if (!access || access.role === 'VIEWER') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { title, totalCycles, startDate, cycleType, cycleDays } = body
|
||||
|
||||
// Validate required fields
|
||||
if (!title || !totalCycles || !startDate) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Calculate estimated end date
|
||||
const start = new Date(startDate)
|
||||
const estimatedEnd = addDays(start, cycleDays * totalCycles)
|
||||
|
||||
// Create or update treatment plan
|
||||
const plan = await prisma.treatmentPlan.upsert({
|
||||
where: { workspaceId },
|
||||
create: {
|
||||
workspaceId,
|
||||
title,
|
||||
totalCycles,
|
||||
startDate: start,
|
||||
estimatedEnd,
|
||||
cycleType,
|
||||
cycleDays,
|
||||
createdById: user.id,
|
||||
milestones: {
|
||||
create: Array.from({ length: totalCycles }, (_, i) => ({
|
||||
cycleNumber: i + 1,
|
||||
date: addDays(start, cycleDays * i),
|
||||
status: i === 0 ? 'UPCOMING' : 'UPCOMING'
|
||||
}))
|
||||
}
|
||||
},
|
||||
update: {
|
||||
title,
|
||||
totalCycles,
|
||||
startDate: start,
|
||||
estimatedEnd,
|
||||
cycleType,
|
||||
cycleDays,
|
||||
},
|
||||
include: {
|
||||
milestones: {
|
||||
orderBy: { cycleNumber: 'asc' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Log audit
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
userId: user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'TREATMENT_PLAN',
|
||||
entityId: plan.id,
|
||||
details: { title, totalCycles }
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({ plan })
|
||||
} catch (error) {
|
||||
console.error('Failed to create treatment plan:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create treatment plan' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
34
nextstep-features/treatment-milestones/app/new/page.tsx
Normal file
34
nextstep-features/treatment-milestones/app/new/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
|
||||
import { Header, PageContainer } from '@/components/layout/header'
|
||||
import { TreatmentPlanForm } from '@/components/treatment/TreatmentPlanForm'
|
||||
import { useApp } from '../provider'
|
||||
|
||||
export default function NewTreatmentPlanPage() {
|
||||
const router = useRouter()
|
||||
const { currentWorkspace } = useApp()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
title="Create Treatment Plan"
|
||||
leftAction={{
|
||||
icon: <ChevronLeft className="w-6 h-6" />,
|
||||
label: 'Back',
|
||||
onClick: () => router.push('/treatment')
|
||||
}}
|
||||
/>
|
||||
<PageContainer className="pt-4">
|
||||
<div className="mb-6">
|
||||
<p className="text-secondary-500">
|
||||
Set up your treatment schedule to track progress and celebrate milestones.
|
||||
</p>
|
||||
</div>
|
||||
<TreatmentPlanForm workspaceId={currentWorkspace.id} />
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
86
nextstep-features/treatment-milestones/app/page.tsx
Normal file
86
nextstep-features/treatment-milestones/app/page.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Plus, Target, ChevronLeft } from 'lucide-react'
|
||||
|
||||
import { Card, LoadingState, Button } from '@/components/ui'
|
||||
import { Header, PageContainer } from '@/components/layout/header'
|
||||
import { TreatmentProgress } from '@/components/treatment/TreatmentProgress'
|
||||
import { useApp } from '../provider'
|
||||
|
||||
export default function TreatmentPage() {
|
||||
const router = useRouter()
|
||||
const { currentWorkspace } = useApp()
|
||||
const [plan, setPlan] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetchPlan = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/workspaces/${currentWorkspace.id}/treatment-plan`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setPlan(data.plan)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch plan:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlan()
|
||||
}, [currentWorkspace.id])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<Header title="Treatment Plan" />
|
||||
<PageContainer>
|
||||
<LoadingState message="Loading treatment plan..." />
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
title="Treatment Plan"
|
||||
leftAction={{
|
||||
icon: <ChevronLeft className="w-6 h-6" />,
|
||||
label: 'Back',
|
||||
onClick: () => router.push('/today')
|
||||
}}
|
||||
/>
|
||||
<PageContainer className="pt-4 space-y-6">
|
||||
{!plan ? (
|
||||
<Card variant="outline" className="text-center py-12">
|
||||
<div className="w-16 h-16 rounded-full bg-primary-100 flex items-center justify-center mx-auto mb-4">
|
||||
<Target className="w-8 h-8 text-primary-600" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-secondary-900 mb-2">
|
||||
No Treatment Plan Yet
|
||||
</h2>
|
||||
<p className="text-secondary-500 mb-6 max-w-xs mx-auto">
|
||||
Create a treatment plan to track your progress through chemotherapy and celebrate milestones.
|
||||
</p>
|
||||
{currentWorkspace.role !== 'VIEWER' && (
|
||||
<Button href="/treatment/new">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Treatment Plan
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
) : (
|
||||
<TreatmentProgress
|
||||
plan={plan}
|
||||
workspaceId={currentWorkspace.id}
|
||||
onUpdate={fetchPlan}
|
||||
/>
|
||||
)}
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Calendar, Clock, Target } from 'lucide-react'
|
||||
|
||||
import { Card, Button, showToast } from '@/components/ui'
|
||||
|
||||
interface TreatmentPlanFormProps {
|
||||
workspaceId: string
|
||||
initialData?: {
|
||||
title: string
|
||||
totalCycles: number
|
||||
startDate: string
|
||||
cycleType: string
|
||||
cycleDays: number
|
||||
}
|
||||
}
|
||||
|
||||
const CYCLE_TYPES = [
|
||||
{ value: 'WEEKLY', label: 'Weekly', days: 7 },
|
||||
{ value: 'BIWEEKLY', label: 'Every 2 weeks', days: 14 },
|
||||
{ value: 'MONTHLY', label: 'Monthly', days: 30 },
|
||||
{ value: 'CUSTOM', label: 'Custom', days: 0 },
|
||||
]
|
||||
|
||||
export function TreatmentPlanForm({ workspaceId, initialData }: TreatmentPlanFormProps) {
|
||||
const router = useRouter()
|
||||
const [title, setTitle] = useState(initialData?.title || '')
|
||||
const [totalCycles, setTotalCycles] = useState(initialData?.totalCycles || 12)
|
||||
const [startDate, setStartDate] = useState(initialData?.startDate || '')
|
||||
const [cycleType, setCycleType] = useState(initialData?.cycleType || 'WEEKLY')
|
||||
const [customDays, setCustomDays] = useState(initialData?.cycleDays || 14)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const selectedCycleType = CYCLE_TYPES.find(t => t.value === cycleType)
|
||||
const cycleDays = cycleType === 'CUSTOM' ? customDays : (selectedCycleType?.days || 7)
|
||||
|
||||
// Calculate estimated end date
|
||||
const estimatedEndDate = startDate
|
||||
? new Date(new Date(startDate).getTime() + (cycleDays * totalCycles * 24 * 60 * 60 * 1000))
|
||||
: null
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!title.trim()) {
|
||||
showToast('Please enter a treatment name', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (!startDate) {
|
||||
showToast('Please select a start date', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const response = await fetch(`/api/workspaces/${workspaceId}/treatment-plan`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: title.trim(),
|
||||
totalCycles,
|
||||
startDate,
|
||||
cycleType,
|
||||
cycleDays,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to create plan')
|
||||
|
||||
showToast('Treatment plan created!', 'success')
|
||||
router.push('/treatment')
|
||||
} catch {
|
||||
showToast('Failed to create plan', 'error')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Treatment Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||
Treatment Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g., Grace's Chemotherapy Plan"
|
||||
className="w-full px-3 py-2 border border-border rounded-lg text-base focus:outline-none focus:ring-2 focus:ring-primary-200 focus:border-primary-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Total Cycles */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||
<Target className="w-4 h-4 inline mr-1" />
|
||||
Total Cycles/Sessions
|
||||
</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="50"
|
||||
value={totalCycles}
|
||||
onChange={(e) => setTotalCycles(parseInt(e.target.value))}
|
||||
className="flex-1"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="50"
|
||||
value={totalCycles}
|
||||
onChange={(e) => setTotalCycles(parseInt(e.target.value) || 1)}
|
||||
className="w-20 px-3 py-2 border border-border rounded-lg text-center"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-secondary-500 mt-1">
|
||||
Number of chemo sessions or treatment cycles
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Start Date */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||
<Calendar className="w-4 h-4 inline mr-1" />
|
||||
Start Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-border rounded-lg text-base focus:outline-none focus:ring-2 focus:ring-primary-200 focus:border-primary-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cycle Frequency */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||
<Clock className="w-4 h-4 inline mr-1" />
|
||||
How Often?
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{CYCLE_TYPES.map((type) => (
|
||||
<button
|
||||
key={type.value}
|
||||
type="button"
|
||||
onClick={() => setCycleType(type.value)}
|
||||
className={`p-3 rounded-lg border-2 text-left transition-all ${
|
||||
cycleType === type.value
|
||||
? 'border-primary-500 bg-primary-50'
|
||||
: 'border-border hover:border-secondary-300'
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium text-secondary-900">{type.label}</span>
|
||||
{type.value !== 'CUSTOM' && (
|
||||
<span className="text-xs text-secondary-500 block">
|
||||
Every {type.days} days
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Days Input */}
|
||||
{cycleType === 'CUSTOM' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||
Days Between Cycles
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="90"
|
||||
value={customDays}
|
||||
onChange={(e) => setCustomDays(parseInt(e.target.value) || 1)}
|
||||
className="w-full px-3 py-2 border border-border rounded-lg text-base focus:outline-none focus:ring-2 focus:ring-primary-200 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Estimated End Date */}
|
||||
{estimatedEndDate && (
|
||||
<div className="p-4 bg-primary-50 rounded-lg">
|
||||
<p className="text-sm text-secondary-600">
|
||||
Estimated completion:
|
||||
</p>
|
||||
<p className="font-semibold text-primary-700">
|
||||
{estimatedEndDate.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
href="/treatment"
|
||||
fullWidth
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={saving}
|
||||
fullWidth
|
||||
>
|
||||
Create Plan
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Trophy, Calendar, TrendingUp, ChevronRight, Flag, PartyPopper } from 'lucide-react'
|
||||
import { format, addDays, differenceInDays } from 'date-fns'
|
||||
|
||||
import { Card, Button } from '@/components/ui'
|
||||
import { showToast } from '@/components/ui'
|
||||
|
||||
interface Milestone {
|
||||
id: string
|
||||
cycleNumber: number
|
||||
date: string
|
||||
status: 'UPCOMING' | 'COMPLETED' | 'SKIPPED'
|
||||
notes?: string
|
||||
celebratedAt?: string
|
||||
}
|
||||
|
||||
interface TreatmentPlan {
|
||||
id: string
|
||||
title: string
|
||||
totalCycles: number
|
||||
currentCycle: number
|
||||
startDate: string | null
|
||||
estimatedEnd: string | null
|
||||
status: 'ACTIVE' | 'PAUSED' | 'COMPLETED'
|
||||
cycleType: string
|
||||
cycleDays: number
|
||||
milestones: Milestone[]
|
||||
}
|
||||
|
||||
interface TreatmentProgressProps {
|
||||
plan: TreatmentPlan | null
|
||||
workspaceId: string
|
||||
onUpdate?: () => void
|
||||
}
|
||||
|
||||
export function TreatmentProgress({ plan, workspaceId, onUpdate }: TreatmentProgressProps) {
|
||||
const [showCelebration, setShowCelebration] = useState(false)
|
||||
const [celebratingMilestone, setCelebratingMilestone] = useState<Milestone | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Check for uncelebrated completed milestones
|
||||
if (plan?.milestones) {
|
||||
const uncelebrated = plan.milestones.find(
|
||||
m => m.status === 'COMPLETED' && !m.celebratedAt
|
||||
)
|
||||
if (uncelebrated) {
|
||||
setCelebratingMilestone(uncelebrated)
|
||||
setShowCelebration(true)
|
||||
// Mark as celebrated
|
||||
markCelebrated(uncelebrated.id)
|
||||
}
|
||||
}
|
||||
}, [plan])
|
||||
|
||||
const markCelebrated = async (milestoneId: string) => {
|
||||
try {
|
||||
await fetch(`/api/workspaces/${workspaceId}/treatment-plan/milestones/${milestoneId}/celebrate`, {
|
||||
method: 'POST'
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to mark celebration:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCompleteCycle = async () => {
|
||||
if (!plan) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/workspaces/${workspaceId}/treatment-plan/complete-cycle`, {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to complete cycle')
|
||||
|
||||
showToast('Cycle completed! Great job!', 'success')
|
||||
onUpdate?.()
|
||||
} catch {
|
||||
showToast('Failed to complete cycle', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
if (!plan) {
|
||||
return (
|
||||
<Card variant="outline" className="text-center py-8">
|
||||
<Trophy className="w-12 h-12 text-secondary-300 mx-auto mb-3" />
|
||||
<h3 className="font-semibold text-secondary-900 mb-1">No Treatment Plan</h3>
|
||||
<p className="text-secondary-500 text-sm mb-4">
|
||||
Set up a treatment plan to track your progress
|
||||
</p>
|
||||
<Button href={`/treatment/new`}>Create Plan</Button>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const progress = (plan.currentCycle / plan.totalCycles) * 100
|
||||
const daysRemaining = plan.estimatedEnd
|
||||
? differenceInDays(new Date(plan.estimatedEnd), new Date())
|
||||
: null
|
||||
|
||||
const milestones = plan.milestones || []
|
||||
const completedCount = milestones.filter(m => m.status === 'COMPLETED').length
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-primary-500 to-primary-600 text-white p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">{plan.title}</h3>
|
||||
<p className="text-primary-100 text-sm">
|
||||
Cycle {plan.currentCycle} of {plan.totalCycles}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-14 h-14 rounded-full bg-white/20 flex items-center justify-center">
|
||||
<Trophy className="w-7 h-7" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between text-sm mb-2">
|
||||
<span className="text-secondary-600">{completedCount} completed</span>
|
||||
<span className="font-semibold text-primary-600">{Math.round(progress)}%</span>
|
||||
</div>
|
||||
<div className="h-3 bg-secondary-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-primary-500 to-primary-400 transition-all duration-500"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-4 mt-4 pt-4 border-t border-border">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-secondary-900">{plan.currentCycle}</p>
|
||||
<p className="text-xs text-secondary-500">Current</p>
|
||||
</div>
|
||||
<div className="text-center border-x border-border">
|
||||
<p className="text-2xl font-bold text-secondary-900">{plan.totalCycles - plan.currentCycle}</p>
|
||||
<p className="text-xs text-secondary-500">Remaining</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-secondary-900">
|
||||
{daysRemaining !== null ? Math.max(0, daysRemaining) : '?'}
|
||||
</p>
|
||||
<p className="text-xs text-secondary-500">Days Left</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="px-4 pb-4 space-y-2">
|
||||
{plan.status === 'ACTIVE' && plan.currentCycle < plan.totalCycles && (
|
||||
<Button
|
||||
onClick={handleCompleteCycle}
|
||||
fullWidth
|
||||
className="bg-gradient-to-r from-green-500 to-green-600 hover:from-green-600 hover:to-green-700"
|
||||
>
|
||||
<PartyPopper className="w-4 h-4 mr-2" />
|
||||
Complete Cycle {plan.currentCycle + 1}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
href="/treatment/details"
|
||||
fullWidth
|
||||
>
|
||||
View Details
|
||||
<ChevronRight className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Milestone Preview */}
|
||||
{milestones.length > 0 && (
|
||||
<Card className="mt-4">
|
||||
<h4 className="font-semibold text-secondary-900 mb-3">Upcoming Milestones</h4>
|
||||
<div className="space-y-2">
|
||||
{milestones
|
||||
.filter(m => m.status === 'UPCOMING')
|
||||
.slice(0, 3)
|
||||
.map(milestone => (
|
||||
<div
|
||||
key={milestone.id}
|
||||
className="flex items-center gap-3 p-2 rounded-lg bg-secondary-50"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-primary-100 flex items-center justify-center flex-shrink-0">
|
||||
<Flag className="w-4 h-4 text-primary-600" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-secondary-900">
|
||||
Cycle {milestone.cycleNumber}
|
||||
</p>
|
||||
<p className="text-xs text-secondary-500">
|
||||
{format(new Date(milestone.date), 'MMM d, yyyy')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Celebration Modal */}
|
||||
{showCelebration && celebratingMilestone && (
|
||||
<MilestoneCelebration
|
||||
milestone={celebratingMilestone}
|
||||
plan={plan}
|
||||
onClose={() => setShowCelebration(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface MilestoneCelebrationProps {
|
||||
milestone: Milestone
|
||||
plan: TreatmentPlan
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function MilestoneCelebration({ milestone, plan, onClose }: MilestoneCelebrationProps) {
|
||||
const messages = [
|
||||
"You're doing amazing!",
|
||||
"One step closer to the finish line!",
|
||||
"Your strength is inspiring!",
|
||||
"Keep going, you've got this!",
|
||||
"Another milestone conquered!"
|
||||
]
|
||||
|
||||
const randomMessage = messages[Math.floor(Math.random() * messages.length)]
|
||||
|
||||
const isHalfway = milestone.cycleNumber === Math.floor(plan.totalCycles / 2)
|
||||
const isFinal = milestone.cycleNumber === plan.totalCycles
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl p-6 max-w-sm w-full text-center animate-in fade-in zoom-in duration-300">
|
||||
<div className="w-20 h-20 rounded-full bg-gradient-to-r from-yellow-400 to-orange-500 flex items-center justify-center mx-auto mb-4 animate-bounce">
|
||||
<PartyPopper className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold text-secondary-900 mb-2">
|
||||
{isFinal ? '🎉 Treatment Complete!' : isHalfway ? '🌟 Halfway There!' : 'Milestone Reached!'}
|
||||
</h2>
|
||||
|
||||
<p className="text-secondary-600 mb-2">
|
||||
Cycle {milestone.cycleNumber} of {plan.totalCycles} completed!
|
||||
</p>
|
||||
|
||||
<p className="text-primary-600 font-medium mb-6">
|
||||
{isFinal ? "You've done it! What an incredible journey." : randomMessage}
|
||||
</p>
|
||||
|
||||
<Button onClick={onClose} fullWidth>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user