Lesson Plans
Lesson Plans in Web/Admin cover planning therapy sessions, setting goals/activities, tracking progress, and generating sharable PDFs. This system manages therapy session planning with critical business logic for goal tracking, activity sequencing, and progress measurement.
Core Concepts
- Goal-Based Planning: SMART goals with measurable outcomes and timelines
- Activity Sequencing: Ordered activities with prerequisites and dependencies
- Progress Tracking: Session notes, goal completion percentages, and milestone tracking
- Status Flow: DRAFT → ACTIVE → COMPLETED → ARCHIVED
- Therapist Assignment: Role-based access with specialization matching
- Child-Specific Customization: Age-appropriate activities and difficulty scaling
Web/Admin Capabilities
1) List & Filter
Business Rules:
- Filter by therapist specialization, child age group, and plan status
- Show only plans accessible to current user's role and permissions
- Display progress indicators and completion percentages
- Sort by creation date, last activity, or completion status
Implementation:
// Filter logic with role-based access and progress calculation
const filtered = rows.filter((plan) => {
// Role-based access control
const hasAccess =
userRole === 'ADMIN' ||
(userRole === 'THERAPIST' && plan.therapist_id === currentUserId) ||
(userRole === 'PARENT' && plan.child_id === currentUserChildId);
// Status and therapist filtering
const matchesStatus = !statusFilter || plan.status === statusFilter;
const matchesTherapist =
!therapistFilter || plan.therapist_id === therapistFilter;
// Progress calculation for display
const progressPercentage = calculateProgress(plan.goals, plan.activities);
plan.progress = progressPercentage;
return hasAccess && matchesStatus && matchesTherapist;
});
// Progress calculation formula
const calculateProgress = (goals, activities) => {
const completedGoals = goals.filter(
(goal) => goal.status === 'COMPLETED',
).length;
const completedActivities = activities.filter(
(activity) => activity.is_completed,
).length;
const totalItems = goals.length + activities.length;
return totalItems > 0
? Math.round(((completedGoals + completedActivities) / totalItems) * 100)
: 0;
};
2) Create Lesson Plan (Admin/Therapist Only)
Critical Business Rules:
- Child-Therapist Matching: Therapist must have specialization matching child's needs
- Age Appropriateness: Activities must be suitable for child's developmental stage
- Goal Validation: SMART goals with measurable outcomes and realistic timelines
- Activity Sequencing: Activities must have proper prerequisites and dependencies
- Session Duration: Total estimated time must not exceed child's attention span
Validation Formula:
// Child-therapist compatibility check
const validateTherapistMatch = (child, therapist) => {
const childNeeds = child.special_needs || [];
const therapistSpecializations = therapist.specializations || [];
return childNeeds.every((need) =>
therapistSpecializations.some((spec) => spec.includes(need)),
);
};
// Age-appropriate activity validation
const validateActivityAge = (activity, childAge) => {
const ageInMonths = childAge * 12;
return ageInMonths >= activity.min_age && ageInMonths <= activity.max_age;
};
// Session duration validation
const validateSessionDuration = (activities, childAge) => {
const totalDuration = activities.reduce(
(sum, activity) => sum + activity.estimated_duration,
0,
);
const maxDuration = childAge <= 3 ? 30 : childAge <= 6 ? 45 : 60; // minutes
return totalDuration <= maxDuration;
};
GraphQL Implementation:
import { gql, ApolloClient } from '@apollo/client';
const CREATE_PLAN = gql`
mutation CreatePlan($input: CreateLessonPlanInput!) {
createLessonPlan(input: $input) {
success
message
data {
id
status
estimated_duration
goals_count
activities_count
}
}
}
`;
async function createPlan(
client: ApolloClient<unknown>,
input: CreateLessonPlanInput,
) {
// Pre-validate business rules
const child = await getChildDetails(input.child_id);
const therapist = await getTherapistDetails(input.therapist_id);
if (!validateTherapistMatch(child, therapist)) {
throw new Error('Therapist specialization does not match child needs');
}
if (!validateSessionDuration(input.activities, child.age)) {
throw new Error("Session duration exceeds child's attention span");
}
// Validate each activity
for (const activity of input.activities) {
if (!validateActivityAge(activity, child.age)) {
throw new Error(
`Activity "${activity.title}" not suitable for child's age`,
);
}
}
const { data } = await client.mutate({
mutation: CREATE_PLAN,
variables: { input },
});
if (!data?.createLessonPlan?.success) {
throw new Error(data?.createLessonPlan?.message || 'Create failed');
}
return data.createLessonPlan.data;
}
3) Activity Management & Sequencing
Activity Business Rules:
- Prerequisite Validation: Activities must have completed prerequisites before starting
- Difficulty Progression: Activities should follow logical difficulty progression
- Resource Requirements: Materials and equipment must be available
- Time Estimation: Realistic duration estimates based on child's abilities
- Adaptive Difficulty: Activities can be scaled up/down based on child's progress
Activity Sequencing Logic:
// Prerequisite validation
const validatePrerequisites = (activity, completedActivities) => {
if (!activity.prerequisites || activity.prerequisites.length === 0) {
return true; // No prerequisites
}
return activity.prerequisites.every((prereq) =>
completedActivities.some((completed) => completed.id === prereq.id),
);
};
// Difficulty progression validation
const validateDifficultyProgression = (newActivity, existingActivities) => {
const maxDifficulty = Math.max(
...existingActivities.map((a) => a.difficulty_level || 0),
);
const minDifficulty = Math.min(
...existingActivities.map((a) => a.difficulty_level || 0),
);
// New activity should be within reasonable range of existing activities
return (
newActivity.difficulty_level >= minDifficulty - 1 &&
newActivity.difficulty_level <= maxDifficulty + 1
);
};
// Resource availability check
const validateResourceAvailability = (activity, availableResources) => {
const requiredResources = activity.required_materials || [];
return requiredResources.every((resource) =>
availableResources.some((available) => available.id === resource.id),
);
};
GraphQL Implementation:
const UPSERT_ACTIVITY = gql`
mutation UpsertActivity($input: UpsertActivityInput!) {
upsertActivity(input: $input) {
success
message
data {
id
title
difficulty_level
estimated_duration
prerequisites_met
resource_availability
}
}
}
`;
async function upsertActivity(
client: ApolloClient<unknown>,
planId: string,
activity: UpsertActivityInput,
) {
// Get current plan state for validation
const plan = await getLessonPlanDetails(planId);
const completedActivities = plan.activities.filter((a) => a.is_completed);
const availableResources = await getAvailableResources();
// Validate prerequisites
if (!validatePrerequisites(activity, completedActivities)) {
throw new Error('Activity prerequisites not met');
}
// Validate difficulty progression
if (!validateDifficultyProgression(activity, plan.activities)) {
throw new Error('Activity difficulty does not follow logical progression');
}
// Validate resource availability
if (!validateResourceAvailability(activity, availableResources)) {
throw new Error('Required materials not available');
}
const { data } = await client.mutate({
mutation: UPSERT_ACTIVITY,
variables: { input: { plan_id: planId, ...activity } },
});
if (!data?.upsertActivity?.success) {
throw new Error(data?.upsertActivity?.message || 'Save failed');
}
return data.upsertActivity.data;
}
4) Progress Tracking & Session Notes
Progress Tracking Business Rules:
- Goal Completion: Track progress toward SMART goals with measurable milestones
- Activity Completion: Mark activities as completed with performance ratings
- Session Notes: Detailed observations and recommendations for future sessions
- Progress Analytics: Calculate completion rates and identify areas needing attention
- Parent Communication: Generate progress reports for parent review
Progress Calculation Formula:
// Goal progress calculation
const calculateGoalProgress = (goal, completedActivities) => {
const relevantActivities = completedActivities.filter((activity) =>
goal.related_activities.includes(activity.id),
);
if (goal.measurement_type === 'COUNT') {
return Math.min((relevantActivities.length / goal.target_count) * 100, 100);
} else if (goal.measurement_type === 'PERCENTAGE') {
const avgPerformance =
relevantActivities.reduce(
(sum, activity) => sum + (activity.performance_rating || 0),
0,
) / relevantActivities.length;
return Math.min((avgPerformance / 5) * 100, 100); // Assuming 5-point scale
}
return 0;
};
// Overall plan progress
const calculatePlanProgress = (plan) => {
const goalProgress = plan.goals.map((goal) =>
calculateGoalProgress(goal, plan.completed_activities),
);
const activityProgress =
(plan.completed_activities.length / plan.total_activities) * 100;
return Math.round(
(goalProgress.reduce((sum, progress) => sum + progress, 0) +
activityProgress) /
(plan.goals.length + 1),
);
};
GraphQL Implementation:
const ADD_NOTE = gql`
mutation AddNote($input: AddLessonPlanNoteInput!) {
addLessonPlanNote(input: $input) {
success
message
data {
id
note
session_date
progress_indicators {
goal_progress
activity_completion
overall_progress
}
}
}
}
`;
async function addSessionNote(
client: ApolloClient<unknown>,
planId: string,
note: string,
sessionData: SessionData,
) {
// Calculate progress before adding note
const plan = await getLessonPlanDetails(planId);
const progressIndicators = {
goal_progress: calculateGoalProgress(
plan.current_goal,
plan.completed_activities,
),
activity_completion:
(plan.completed_activities.length / plan.total_activities) * 100,
overall_progress: calculatePlanProgress(plan),
};
const { data } = await client.mutate({
mutation: ADD_NOTE,
variables: {
input: {
plan_id: planId,
note,
session_date: sessionData.date,
activities_completed: sessionData.completed_activities,
performance_ratings: sessionData.performance_ratings,
},
},
});
if (!data?.addLessonPlanNote?.success) {
throw new Error(data?.addLessonPlanNote?.message || 'Add note failed');
}
return data.addLessonPlanNote.data;
}
5) Export & Reporting
Export Business Rules:
- Progress Reports: Include goal progress, activity completion, and session notes
- Parent Reports: Generate child-friendly progress summaries
- Therapist Reports: Detailed analytics for therapy planning
- Compliance Reports: Track session attendance and goal achievement
Export Implementation:
// Generate comprehensive lesson plan report
const generateLessonPlanReport = (plans: LessonPlan[]) => {
return plans.map((plan) => ({
id: plan.id,
child_name: plan.child.name,
therapist_name: plan.therapist.name,
start_date: plan.start_date,
current_progress: calculatePlanProgress(plan),
goals_summary: plan.goals.map((goal) => ({
title: goal.title,
progress: calculateGoalProgress(goal, plan.completed_activities),
status: goal.status,
})),
activities_summary: {
total: plan.total_activities,
completed: plan.completed_activities.length,
completion_rate:
(plan.completed_activities.length / plan.total_activities) * 100,
},
last_session_date: plan.last_session_date,
next_session_date: plan.next_session_date,
}));
};
const csv = toCsv(generateLessonPlanReport(rows));
downloadCsv('lesson-plans-progress-report.csv', csv);
Data Flow (Web ↔ Backend)
Critical Business Logic Flow:
- Creation: Admin/Therapist creates plan → Child-therapist matching → Age validation → Goal setting
- Activity Management: Add activities → Prerequisite validation → Resource check → Sequencing
- Progress Tracking: Session notes → Progress calculation → Goal updates → Parent communication
- Reporting: Generate reports → Export data → Analytics → Compliance tracking
GraphQL Operations with Business Rules:
| Action | Operation | Business Rules |
|---|---|---|
| Create Plan | createLessonPlan(input) | Child-therapist matching, age appropriateness, session duration |
| Upsert Activity | upsertActivity(input) | Prerequisite validation, difficulty progression, resource availability |
| Add Note | addLessonPlanNote(input) | Progress calculation, goal tracking, session analytics |
| List/Filter | lessonPlans(filter) | Role-based access, progress indicators, completion status |
| Export | exportLessonPlanReport(input) | Progress analytics, compliance tracking, parent communication |
Error Handling & Validation:
- Therapist Mismatch: "Therapist specialization does not match child needs"
- Age Inappropriate: "Activity not suitable for child's developmental stage"
- Prerequisites Not Met: "Activity prerequisites not completed"
- Resource Unavailable: "Required materials not available"
- Session Too Long: "Session duration exceeds child's attention span"
Security & Access Control
- Role-Based Access: Admins can access all plans, therapists only their assigned plans, parents only their child's plans
- Data Privacy: Session notes and progress data protected by user permissions
- Audit Trail: All plan modifications and progress updates are logged
- Parent Communication: Controlled sharing of progress reports with parents
- Compliance: Session tracking and goal achievement for regulatory requirements
Bulk Upload
Use the admin bulk uploader to create multiple lesson plans at once. A single template is supported.
Template
- Required headers:
title,description,min_age,max_age,language,section_name,assessors,subscription_plans,activity_1 - Optional headers:
activity_2…activity_9
Validation
title,descriptionrequiredmin_age,max_agemust be non-negative integers andmax_age >= min_agelanguagemust match reference languagessection_namemust match a valid lesson type (enum derived fromsection_name)assessorsmust map to known specialties; at least onesubscription_plansmust map to known plans; at least one- At least one activity (
activity_1) is required; up to 9 supported
General behavior
- Header matching ignores spaces/special chars (case-insensitive)
- Preview lists row number and validation errors; only valid rows are submitted
- Backend payload includes resolved ids for
language,assessors,subscription_plans, and structured activities
Implementation (TypeScript)
// Header normalization and mapping
const clean = (s: string) =>
s
.replace(/\s+/g, '')
.replace(/[^a-zA-Z0-9]/g, '')
.toLowerCase();
function validateAndMapHeaders(headers: string[]) {
const cleanedExpected = Headers.map((h) => ({
original: h,
cleaned: clean(h),
})); // from constants
const headerMap: Record<string, string> = {};
const unmapped: string[] = [];
headers.forEach((h) => {
const m = cleanedExpected.find((e) => e.cleaned === clean(h));
m ? (headerMap[h] = m.original) : unmapped.push(h);
});
const missingRequired = RequiredHeaders.filter(
(r) => !headers.map(clean).includes(clean(r)),
);
return {
isValid: missingRequired.length === 0,
missingRequired,
unmappedHeaders: unmapped,
headerMap,
};
}
// Row validation + transform
function validateAndTransformData(rows: CSVRow[], ref: ReferenceData) {
const display: DisplayLessonPlan[] = [];
const backend: BackendLessonPlan[] = [];
const toEnum = (s: string) =>
s
.toUpperCase()
.replace(/[^A-Z0-9]/g, '_')
.replace(/_+/g, '_')
.replace(/^_|_$/g, '');
rows.forEach((row, idx) => {
const errors: string[] = [];
if (!row.title?.trim()) errors.push('Title is required');
if (!row.description?.trim()) errors.push('Description is required');
const minAge = parseInt(row.min_age);
const maxAge = parseInt(row.max_age);
if (isNaN(minAge) || minAge < 0) errors.push('Min age must be >= 0');
if (isNaN(maxAge) || maxAge < 0) errors.push('Max age must be >= 0');
if (!isNaN(minAge) && !isNaN(maxAge) && maxAge < minAge)
errors.push('Max age must be >= Min age');
const language = ref.languages.find(
(l) => l.title.toLowerCase() === row.language?.toLowerCase(),
);
if (!language) errors.push('Invalid language');
const sectionEnum = toEnum(row.section_name || '');
const lessonType = ref.lessonTypes.find((lt) => lt.name === sectionEnum);
if (!row.section_name?.trim()) errors.push('Section name is required');
if (row.section_name?.trim() && !lessonType)
errors.push('Invalid section name');
const assessorTitles =
row.assessors
?.split(',')
.map((s) => s.trim())
.filter(Boolean) || [];
const assessorIds = assessorTitles
.map(
(t) =>
ref.specialities.find(
(s) => s.title.toLowerCase() === t.toLowerCase(),
)?.id,
)
.filter((id): id is string => !!id);
if (assessorIds.length !== assessorTitles.length)
errors.push('Invalid assessors');
if (assessorIds.length === 0) errors.push('At least one assessor required');
const subTitles =
row.subscription_plans
?.split(',')
.map((s) => s.trim())
.filter(Boolean) || [];
const subIds = subTitles
.map(
(t) =>
ref.subscriptionPlans.find(
(p) => p.name.toLowerCase() === t.toLowerCase(),
)?.id,
)
.filter((id): id is string => !!id);
if (subIds.length !== subTitles.length)
errors.push('Invalid subscription plans');
if (subIds.length === 0)
errors.push('At least one subscription plan required');
const activities: Activity[] = [];
let counter = 0;
for (let i = 1; i <= 9; i++) {
const v = row[`activity_${i}` as keyof CSVRow]?.trim();
if (v) {
counter++;
activities.push({ heading: `Activity ${counter}`, description: v });
}
}
if (activities.length === 0)
errors.push('At least one activity is required');
display.push({
title: row.title?.trim() || '',
description: row.description?.trim() || '',
min_age: minAge,
max_age: maxAge,
language_title: language?.title || row.language || '',
language_id: language?.id || '',
section_name: row.section_name?.trim() || '',
lesson_type: lessonType?.name || 'OTHERS',
assessor_titles: assessorTitles,
assessor_ids: assessorIds,
subscription_titles: subTitles,
subscription_ids: subIds,
activities: activities.map((a) => a.description),
row_number: idx + 2,
validation_errors: errors,
});
if (errors.length === 0) {
backend.push({
name: row.title!.trim(),
description: row.description!.trim(),
min_age: minAge,
max_age: maxAge,
language_id: language!.id,
lesson_type: lessonType!.name,
lesson_plan_specialties: assessorIds,
lesson_plan_subscriptions: subIds,
activities,
});
}
});
return { display, backend };
}