Last active
June 25, 2025 21:40
-
-
Save germanny/dbfbd9c4634015ca21ec309736771353 to your computer and use it in GitHub Desktop.
Calculator Architecture System
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Backend implementation of a calculator engine with modules, validation, and caching | |
/**File structure example: | |
/services/ | |
calculatorEngineService.ts # Singleton instance export | |
/calculator-engine/ | |
CalculatorEngineService.ts # Main engine class | |
/modules/ | |
BaseCalculationModule.ts # Abstract base class | |
ProgramLengthModule.ts # Program length calculations | |
AffordabilityCalculationModule.ts # Affordability calculator | |
CareerPathwaysCalculationModule.ts # Career pathways calculations | |
/services/ | |
ValidationService.ts # Shared validation | |
CacheService.ts # Caching logic (if needed) | |
ABTestingService.ts # A/B testing integration | |
*/ | |
// create a service that creates an instance of CalculatorEngineService | |
// and provides methods to interact with it | |
import { CalculatorEngineService } from '@/services/calculatorEngineService'; | |
const calculatorEngineService = new CalculatorEngineService(); | |
export { calculatorEngineService }; | |
interface CalculatorConfig { | |
id: string; | |
name: string; | |
inputs: InputField[]; | |
calculations: CalculationRule[]; | |
outputs: OutputField[]; | |
leadRouting: LeadRoutingConfig; | |
analytics: AnalyticsConfig; | |
} | |
interface CalculatorEngine { | |
execute(calculatorId: string, inputs: InputData): Promise<CalculationResult>; | |
validate(calculatorId: string, inputs: InputData): ValidationResult; | |
getConfiguration(calculatorId: string): CalculatorConfig; | |
} | |
// loads configurations from YAML files in the configs directory | |
// validates inputs using a validation service's rules (e.g., AJV or custom validation (provided below)) | |
// checks cache for previous calculated results | |
// routes to the appropriate calculation module based on the calculatorId | |
// caches the result and tracks the calculation using analytics services (if needed) | |
class CalculatorEngineService implements CalculatorEngine { | |
private modules: Map<string, CalculationModule> = new Map(); | |
private configs: Map<string, CalculatorConfig> = new Map(); | |
private cache: CacheService; | |
private validator: ValidationService; | |
private abTesting: ABTestingService; | |
private dataService: DataService; | |
constructor() { | |
// Load configurations at startup | |
this.loadConfigurations(); | |
// Initialize services | |
this.cache = new CacheService(); | |
this.validator = new ValidationService(); | |
this.abTesting = new ABTestingService(); | |
this.dataService = new DataService(); | |
// Register calculation modules (from Implementation 2) | |
this.modules.set('program-length', new ProgramLengthCalculationModule()); | |
this.modules.set('affordability-calculator', new AffordabilityCalculationModule()); | |
this.modules.set('career-pathways', new CareerPathwaysCalculationModule()); | |
this.modules.set('program-comparison', new ProgramComparisonCalculationModule()); | |
} | |
async execute(calculatorId: string, inputs: InputData): Promise<CalculationResult> { | |
const config = this.getConfiguration(calculatorId); | |
// Validate inputs | |
const validation = this.validate(calculatorId, inputs); | |
if (!validation.isValid) { | |
throw new ValidationError(validation.errors); | |
} | |
// Check cache | |
const cacheKey = this.generateCacheKey(calculatorId, inputs); | |
const cached = await this.cache.get(cacheKey); | |
if (cached) return cached; | |
// Execute calculation - pass config to avoid refetching | |
const result = await this.executeCalculationPipeline(config, inputs); | |
// Cache and track | |
await this.cache.set(cacheKey, result); | |
this.trackCalculation(calculatorId, inputs, result); | |
return result; | |
} | |
private async executeCalculationPipeline( | |
config: CalculatorConfig, | |
inputs: InputData | |
): Promise<CalculationResult> { | |
const module = this.modules.get(config.id); | |
if (!module) { | |
throw new Error(`No calculation module found for ${config.id}`); | |
} | |
// Let each module handle its own data enrichment | |
const enrichedInputs = await module.enrichInputs(inputs, this.dataService); | |
return module.calculate(enrichedInputs, config); | |
} | |
private loadConfigurations(): void { | |
// Load configurations from YAML files in the configs directory | |
const configDir = path.join(__dirname, './configs'); | |
const configFiles = fs.readdirSync(configDir).filter(file => file.endsWith('.yaml')); | |
configFiles.forEach(configId => { | |
try { | |
const configData = yaml.load(fs.readFileSync(`${configDir}/${configId}`, 'utf8')); | |
// VALIDATE BEFORE LOADING using AJV | |
const validation = ConfigurationValidator.validate(configData); | |
if (!validation.isValid) { | |
throw new Error(`Invalid configuration in ${configId}: ${validation.errors.join(', ')}`); | |
} | |
this.configs.set(configId.replace('.yaml', ''), configData.calculator); | |
} catch (error) { | |
console.error(`Failed to load configuration for ${configId}:`, error); | |
throw new Error(`Calculator system initialization failed: ${error.message}`); | |
} | |
}); | |
} | |
async validate(calculatorId: string, inputs: InputData): ValidationResult { | |
const config = this.getConfiguration(calculatorId); | |
const module = this.modules.get(calculatorId); | |
if (!module) { | |
throw new Error(`No calculation module found for ${calculatorId}`); | |
} | |
// Use both generic validation and module-specific validation | |
const genericValidation = this.validator.validate(inputs, config); | |
const moduleValidation = module.validate(inputs, config); | |
return { | |
isValid: genericValidation.isValid && moduleValidation.isValid, | |
errors: [...genericValidation.errors, ...moduleValidation.errors] | |
}; | |
} | |
// Internal method that retrieves configurations from memory/cache within the backend service | |
// Used by: Other methods within this service | |
getConfiguration(calculatorId: string): CalculatorConfig { | |
const config = this.configs.get(calculatorId); | |
if (!config) { | |
throw new Error(`No configuration found for calculator: ${calculatorId}`); | |
} | |
return config; | |
} | |
private generateCacheKey(calculatorId: string, inputs: InputData): string { | |
const sortedInputs = Object.keys(inputs) | |
.sort() | |
.reduce((result, key) => { | |
result[key] = inputs[key]; | |
return result; | |
}, {} as InputData); | |
return `calc:${calculatorId}:${this.hashInputs(sortedInputs)}`; | |
} | |
private hashInputs(inputs: InputData): string { | |
return Buffer.from(JSON.stringify(inputs)).toString('base64'); | |
} | |
private trackCalculation(calculatorId: string, inputs: InputData, result: CalculationResult): void { | |
// Integration with our existing New Relic/Cohesion tracking | |
// This would call our existing analytics services | |
} | |
} | |
// Validator and other services | |
// This might be AJV or similar, but for simplicity, we will use a custom validation service | |
class ValidationService { | |
validate(inputs: InputData, config: CalculatorConfig): ValidationResult { | |
const errors: ValidationError[] = []; | |
// Required field validation | |
config.inputs.forEach(field => { | |
if (field.required && !inputs[field.name]) { | |
errors.push({ | |
field: field.name, | |
message: `${field.displayName} is required`, | |
code: 'REQUIRED_FIELD' | |
}); | |
} | |
}); | |
// Type validation | |
config.inputs.forEach(field => { | |
const value = inputs[field.name]; | |
if (value && !this.validateFieldType(value, field.type)) { | |
errors.push({ | |
field: field.name, | |
message: `Invalid ${field.type} value`, | |
code: 'INVALID_TYPE' | |
}); | |
} | |
}); | |
// Business rule validation | |
const businessRuleErrors = this.validateBusinessRules(inputs, config); | |
errors.push(...businessRuleErrors); | |
return { | |
isValid: errors.length === 0, | |
errors | |
}; | |
} | |
private validateBusinessRules(inputs: InputData, config: CalculatorConfig): ValidationError[] { | |
const errors: ValidationError[] = []; | |
// Example: Program length specific validation | |
if (config.id === 'program-length') { | |
if (inputs.classesPerSemester > 6) { | |
errors.push({ | |
field: 'classesPerSemester', | |
message: 'Maximum 6 classes per semester recommended', | |
code: 'BUSINESS_RULE_VIOLATION' | |
}); | |
} | |
} | |
return errors; | |
} | |
} | |
class ABTestingService { | |
private monarch: MonarchClient; | |
async getTestVariant(calculatorId: string, userId: string): Promise<TestVariant> { | |
return await this.monarch.getVariant(`calculator-${calculatorId}`, userId); | |
} | |
applyVariant(config: CalculatorConfig, variant: TestVariant): CalculatorConfig { | |
// Apply test variations to calculator configuration | |
const modifiedConfig = { ...config }; | |
switch (variant.name) { | |
case 'simplified-inputs': | |
modifiedConfig.inputs = modifiedConfig.inputs.filter(input => | |
input.priority === 'high' | |
); | |
break; | |
case 'enhanced-results': | |
modifiedConfig.outputs.push({ | |
name: 'studyTips', | |
type: 'text', | |
value: 'Additional study recommendations' | |
}); | |
break; | |
} | |
return modifiedConfig; | |
} | |
trackTestEvent(calculatorId: string, variant: TestVariant, event: string, data: any): void { | |
this.monarch.track({ | |
testId: `calculator-${calculatorId}`, | |
variant: variant.name, | |
event, | |
userId: data.userId, | |
properties: data | |
}); | |
} | |
} | |
class ValidationError extends Error { | |
constructor(public errors: ValidationError[]) { | |
super('Validation failed'); | |
this.name = 'ValidationError'; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class DataSourceFactory { | |
static create(type?: string): DataSource { | |
const sourceType = type || process.env.DATA_SOURCE || 'algolia'; | |
switch (sourceType) { | |
case 'algolia': | |
return new AlgoliaDataSource(); | |
case 'database': | |
return new DatabaseDataSource(); | |
case 'combined': | |
return new CombinedDataSource(); // Could combine Algolia + Career API | |
default: | |
return new AlgoliaDataSource(); | |
} | |
} | |
} | |
// Example of a combined data source | |
class CombinedDataSource implements DataSource { | |
private algoliaSource: AlgoliaDataSource; | |
private careerService: CareerGraphQLService; | |
constructor() { | |
this.algoliaSource = new AlgoliaDataSource(); | |
this.careerService = new CareerGraphQLService(); | |
} | |
async getProgram(programId: string): Promise<ProgramData> { | |
return await this.algoliaSource.getProgram(programId); | |
} | |
async getCareerPathway(inputs: CareerPathwayInputs): Promise<CareerPathwayResponse> { | |
return await this.careerService.getCareerPathway(inputs); | |
} | |
} | |
// Simple DataService wrapper to demonstrate how we can fetch program data or career pathways from different sources | |
class DataService { | |
private dataSource: DataSource; | |
private careerService?: CareerGraphQLService; | |
constructor() { | |
this.dataSource = DataSourceFactory.create(); | |
// Initialize career service if available | |
if (process.env.CAREER_API_ENDPOINT) { | |
this.careerService = new CareerGraphQLService(); | |
} | |
} | |
// Program data | |
async getProgram(programId: string): Promise<ProgramData> { | |
return await this.dataSource.getProgram(programId); | |
} | |
searchPrograms(criteria: SearchCriteria): Promise<Program[]>; | |
// Career data | |
async getCareerPathway(inputs: CareerPathwayInputs): Promise<CareerPathwayResponse> { | |
// Try the data source first (if it supports career data) | |
if (this.dataSource.getCareerPathway) { | |
return await this.dataSource.getCareerPathway(inputs); | |
} | |
// Fall back to dedicated career service | |
if (this.careerService) { | |
return await this.careerService.getCareerPathway(inputs); | |
} | |
throw new Error('Career pathway data not available'); | |
} | |
// School data (future) | |
getSchool?(schoolId: string): Promise<SchoolData>; | |
// Market data (future) | |
getJobMarketData?(criteria: JobMarketCriteria): Promise<JobMarketData>; | |
// Salary data (future) | |
getSalaryData?(role: string, location: string): Promise<SalaryData>; | |
} | |
// DataSource interface with example methods for fetching program data and career pathways | |
interface DataSource { | |
// Program data | |
getProgram(programId: string): Promise<ProgramData>; | |
getProgramsByIds?(programIds: string[]): Promise<Program[]>; | |
searchPrograms(criteria: SearchCriteria): Promise<Program[]>; | |
// Career data | |
getCareerPathway?(inputs: CareerPathwayInputs): Promise<CareerPathwayResponse>; | |
// Future methods: | |
getSchool?(schoolId: string): Promise<SchoolData>; | |
getJobMarketData?(criteria: JobMarketCriteria): Promise<JobMarketData>; | |
getSalaryData?(role: string, location: string): Promise<SalaryData>; | |
} | |
// This is where we would fetch data from Algolia or other data sources | |
import { AlgoliaClient } from 'algoliasearch'; | |
class AlgoliaDataSource implements DataSource { | |
private client: AlgoliaClient; | |
private programsIndex: string; | |
constructor() { | |
this.client = new AlgoliaClient({ | |
appId: process.env.ALGOLIA_APP_ID, | |
apiKey: process.env.ALGOLIA_API_KEY | |
}); | |
this.programsIndex = 'programs'; // Your Algolia index name | |
} | |
async getProgram(programId: string): Promise<ProgramData> { | |
// Fetch program data from Algolia or other data source | |
try { | |
const index = this.client.initIndex(this.programsIndex); | |
const program = await index.getObject(programId); | |
return { | |
degreeType: program.degreeType, | |
totalCredits: program.totalCredits, | |
prerequisiteCredits: program.prerequisites, | |
// ... other program fields | |
}; | |
} catch (error) { | |
console.error(`Failed to fetch program ${programId}:`, error); | |
throw new Error(`Program ${programId} not found`); | |
} | |
} | |
async searchPrograms(searchParams: AlgoliaSearchParams): Promise<Program[]> { | |
try { | |
const index = this.client.initIndex('programs'); | |
const searchResults = await index.search('', { | |
filters: searchParams.filters, | |
facetFilters: searchParams.facetFilters, | |
hitsPerPage: searchParams.hitsPerPage, | |
attributesToRetrieve: searchParams.attributesToRetrieve | |
}); | |
return searchResults.hits.map(hit => this.mapAlgoliaToProgramData(hit)); | |
} catch (error) { | |
console.error('Program search failed:', error); | |
throw new Error('Unable to search programs at this time'); | |
} | |
} | |
async getProgramsByIds(programIds: string[]): Promise<Program[]> { | |
try { | |
const index = this.client.initIndex('programs'); | |
const results = await index.getObjects(programIds); | |
return results.map(program => this.mapAlgoliaToProgramData(program)); | |
} catch (error) { | |
console.error('Failed to fetch programs by IDs:', error); | |
throw new Error('Unable to fetch program details'); | |
} | |
} | |
} | |
class CareerGraphQLService { | |
private client: GraphQLClient; | |
constructor() { | |
this.client = new GraphQLClient(process.env.CAREER_API_ENDPOINT!, { | |
headers: { | |
Authorization: `Bearer ${process.env.CAREER_API_TOKEN}` | |
} | |
}); | |
} | |
async getCareerPathway(inputs: { | |
currentEducation: string; | |
fieldOfStudy: string; | |
currentRole: string; | |
targetRole: string; | |
}): Promise<CareerPathwayResponse> { | |
const query = ` | |
query GetCareerPathway( | |
$currentEducation: String! | |
$fieldOfStudy: String! | |
$currentRole: String! | |
$targetRole: String! | |
) { | |
careerPathway( | |
currentEducation: $currentEducation | |
fieldOfStudy: $fieldOfStudy | |
currentRole: $currentRole | |
targetRole: $targetRole | |
) { | |
steps { | |
title | |
duration | |
description | |
keySkills | |
requirements | |
recommendations | |
} | |
salaryData { | |
currentSalary | |
targetSalary | |
percentageIncrease | |
} | |
confidence | |
alternativePathways { | |
title | |
description | |
duration | |
} | |
} | |
} | |
`; | |
try { | |
const response = await this.client.request(query, inputs); | |
return response.careerPathway; | |
} catch (error) { | |
console.error('Career pathway API error:', error); | |
throw new Error('Failed to fetch career pathway data'); | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Ajv from 'ajv'; | |
const ajv = new Ajv({ allErrors: true }); | |
// Define the schema using AJV format | |
const calculatorConfigSchema = { | |
type: 'object', | |
properties: { | |
calculator: { | |
type: 'object', | |
properties: { | |
id: { | |
type: 'string', | |
pattern: '^[a-z-]+$', | |
minLength: 3, | |
maxLength: 50 | |
}, | |
name: { | |
type: 'string', | |
minLength: 3, | |
maxLength: 100 | |
}, | |
module: { | |
type: 'string', | |
enum: [ | |
'AffordabilityCalculationModule', | |
'ProgramLengthCalculationModule', | |
'CareerPathwaysCalculationModule', | |
'ProgramComparisonCalculationModule' | |
] | |
}, | |
inputs: { | |
type: 'array', | |
minItems: 1, | |
items: { | |
type: 'object', | |
properties: { | |
name: { type: 'string', pattern: '^[a-zA-Z][a-zA-Z0-9_]*$' }, | |
type: { type: 'string', enum: ['number', 'select', 'multi-select', 'text', 'slider'] }, | |
required: { type: 'boolean', default: false }, | |
min: { type: 'number' }, | |
max: { type: 'number' }, | |
options: { type: 'array', items: { type: 'string' } } | |
}, | |
required: ['name', 'type'], | |
additionalProperties: false | |
} | |
} | |
}, | |
required: ['id', 'name', 'module', 'inputs'], | |
additionalProperties: false | |
} | |
}, | |
required: ['calculator'], | |
additionalProperties: false | |
}; | |
const validateCalculatorConfig = ajv.compile(calculatorConfigSchema); | |
export class ConfigurationValidator { | |
static validate(configData: any): { isValid: boolean; errors: string[] } { | |
const isValid = validateCalculatorConfig(configData); | |
if (!isValid) { | |
const errors = validateCalculatorConfig.errors?.map(error => | |
`${error.instancePath || 'root'}: ${error.message}` | |
) || []; | |
return { isValid: false, errors }; | |
} | |
return { isValid: true, errors: [] }; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
abstract class BaseCalculationModule { | |
abstract calculate(inputs: InputData, config: CalculatorConfig): CalculationResult; | |
abstract validate(inputs: InputData, config: CalculatorConfig): ValidationResult; | |
abstract getRequiredFields(): string[]; | |
// Let each module define what data it needs | |
protected async enrichInputs(inputs: InputData, dataService: DataService): Promise<InputData> { | |
// Default: no enrichment | |
return inputs; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class ProgramLengthCalculationModule extends BaseCalculationModule { | |
constructor(private algoliaService: AlgoliaService) { | |
super(); | |
} | |
// Override to define program-specific data needs | |
protected async enrichInputs(inputs: InputData, dataService: DataService): Promise<InputData> { | |
let enrichedInputs = { ...inputs }; | |
// This module needs program data | |
if (inputs.programId) { | |
const programData = await dataService.getProgram(inputs.programId); | |
enrichedInputs = { ...enrichedInputs, ...programData }; | |
} | |
return enrichedInputs; | |
} | |
async calculate(inputs: InputData, config: CalculatorConfig): CalculationResult { | |
const { creditsCompleted, totalCreditsRequired, degreeType, workCommitment } = inputs; | |
const remainingCredits = totalCreditsRequired - creditsCompleted; | |
const courseLoad = this.getCourseLoadForCommitment(workCommitment); | |
const creditsPerSemester = courseLoad * 3; // 3 credits per course | |
const semesters = Math.ceil(remainingCredits / creditsPerSemester); | |
const timeToCompletion = this.calculateTimeToCompletion(semesters, degreeType); | |
return { | |
timeToCompletion, | |
semestersRemaining: semesters, | |
recommendedCourseLoad: courseLoad, | |
pathwayOptions: this.generatePathwayOptions(remainingCredits, workCommitment) | |
}; | |
} | |
validate(inputs: InputData, config: CalculatorConfig): ValidationResult { | |
const errors: ValidationError[] = []; | |
// Check required fields, for example: | |
const requiredFields = this.getRequiredFields(); | |
requiredFields.forEach(field => { | |
if (!inputs[field]) { | |
errors.push({ | |
field, | |
message: `${field} is required`, | |
code: 'REQUIRED_FIELD' | |
}); | |
} | |
}); | |
return { | |
isValid: errors.length === 0, | |
errors | |
}; | |
} | |
getRequiredFields(): string[] { | |
return ['creditsCompleted', 'totalCreditsRequired', 'degreeType', 'workCommitment']; | |
} | |
private getCourseLoadForCommitment(commitment: string): number { | |
const loadMap = { | |
'full-time': 5, | |
'part-time': 2, | |
'working-professional': 3 | |
}; | |
return loadMap[commitment] || 3; | |
} | |
private calculateTimeToCompletion(semesters: number, degreeType: string): string { | |
const semesterMap = { | |
'associate': 2, | |
'bachelor': 4, | |
'master': 2 | |
}; | |
const years = Math.ceil(semesters / semesterMap[degreeType]); | |
return `${years} year(s)`; | |
} | |
private generatePathwayOptions(remainingCredits: number, workCommitment: string): string[] { | |
// Generate pathway options based on remaining credits and work commitment | |
const options = []; | |
if (workCommitment === 'full-time') { | |
options.push('Accelerated pathway with summer courses'); | |
} else if (workCommitment === 'part-time') { | |
options.push('Evening classes or online courses'); | |
} | |
if (remainingCredits > 30) { | |
options.push('Consider credit transfer from previous studies'); | |
} | |
return options; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class AffordabilityCalculationModule extends BaseCalculationModule { | |
// Override - this module doesn't need external data | |
protected async enrichInputs(inputs: InputData, dataService: DataService): Promise<InputData> { | |
// No external data needed for affordability calculations | |
return inputs; | |
} | |
calculate(inputs: InputData, config: CalculatorConfig): CalculationResult { | |
const { income, financialAid, grants, expenses, tuitionCost } = inputs; | |
const totalAid = financialAid + grants; | |
const netCost = tuitionCost - totalAid; | |
const monthlyPayment = netCost / 48; // 4 years | |
const affordabilityRatio = monthlyPayment / (income / 12); | |
return { | |
monthlyPayment, | |
affordabilityRatio, | |
estimatedMonthlyPayment: this.getEstimatedMonthlyPayment(affordabilityRatio), | |
breakdown: { | |
tuitionCost, | |
totalAid, | |
netCost, | |
estimatedMonthlyPayment: monthlyPayment, | |
estimatedAnnualTuition: netCost / 4 // Assuming 4 years | |
} | |
}; | |
} | |
private getEstimatedMonthlyPayment(ratio: number): number { | |
if (ratio < 0.15) { | |
return 500; // Example value | |
} else if (ratio < 0.3) { | |
return 1000; // Example value | |
} else { | |
return 1500; // Example value | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// The user will input the four pieces of info - current education level (bachelors, high school, masters, etc.), what field of study (business, computer science, etc.), their current role (marketing assistant, business analyst, etc.), and what their target role is (marketing director, senior developer, etc.). | |
class CareerPathwaysCalculationModule extends BaseCalculationModule { | |
constructor(private dataService: DataService) { | |
super(); | |
} | |
// Override to define career-specific data needs | |
protected async enrichInputs(inputs: InputData, dataService: DataService): Promise<InputData> { | |
let enrichedInputs = { ...inputs }; | |
// This module needs career pathway data | |
const careerData = await dataService.getCareerPathway({ | |
currentEducation: inputs.currentEducation, | |
fieldOfStudy: inputs.fieldOfStudy, | |
currentRole: inputs.currentRole, | |
targetRole: inputs.targetRole | |
}); | |
// Add career data to inputs | |
enrichedInputs.careerPathwayData = careerData; | |
return enrichedInputs; | |
} | |
async calculate(inputs: InputData, config: CalculatorConfig): Promise<CalculationResult> { | |
const { currentEducation, fieldOfStudy, currentRole, targetRole } = inputs; | |
// Call GraphQL API to get career pathway data | |
const pathwayData = await this.dataService.getCareerPathway({ | |
currentEducation, | |
fieldOfStudy, | |
currentRole, | |
targetRole | |
}); | |
// Process the raw data into structured steps | |
const processedSteps = this.processPathwaySteps(pathwayData.steps); | |
const timeEstimate = this.calculateTotalTime(processedSteps); | |
const salaryIncrease = this.calculateSalaryIncrease(pathwayData.salaryData); | |
return { | |
careerPathway: { | |
currentRole, | |
targetRole, | |
steps: processedSteps, | |
totalTimeEstimate: timeEstimate, | |
avgSalaryIncrease: salaryIncrease | |
}, | |
metadata: { | |
fieldOfStudy, | |
currentEducation, | |
calculatedAt: new Date(), | |
confidence: pathwayData.confidence || 0.85 | |
} | |
}; | |
} | |
private processPathwaySteps(rawSteps: any[]): PathwayStep[] { | |
return rawSteps.map((step, index) => ({ | |
stepNumber: index + 1, | |
title: step.title, | |
duration: step.duration, | |
description: step.description, | |
keySkills: step.keySkills || [], | |
requirements: step.requirements || [], | |
recommendations: step.recommendations || [] | |
})); | |
} | |
private calculateTotalTime(steps: PathwayStep[]): string { | |
const totalMonths = steps.reduce((total, step) => { | |
// Parse duration like "1-2 years" to get average months | |
const months = this.parseDurationToMonths(step.duration); | |
return total + months; | |
}, 0); | |
const years = Math.round(totalMonths / 12); | |
return `${years-1}-${years+1} years`; | |
} | |
private calculateSalaryIncrease(salaryData: any): string { | |
const increase = salaryData?.percentageIncrease || 35; | |
return `+${increase}%`; | |
} | |
private parseDurationToMonths(duration: string): number { | |
// Convert "1-2 years" to average months (18 months) | |
const match = duration.match(/(\d+)-(\d+)\s+years?/); | |
if (match) { | |
const min = parseInt(match[1]); | |
const max = parseInt(match[2]); | |
return ((min + max) / 2) * 12; | |
} | |
return 12; // Default to 1 year | |
} | |
validate(inputs: InputData, config: CalculatorConfig): ValidationResult { | |
const errors: ValidationError[] = []; | |
const required = ['currentEducation', 'fieldOfStudy', 'currentRole', 'targetRole']; | |
required.forEach(field => { | |
if (!inputs[field]) { | |
errors.push({ | |
field, | |
message: `${field} is required`, | |
code: 'REQUIRED_FIELD' | |
}); | |
} | |
}); | |
return { | |
isValid: errors.length === 0, | |
errors | |
}; | |
} | |
getRequiredFields(): string[] { | |
return ['currentEducation', 'fieldOfStudy', 'currentRole', 'targetRole']; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class ProgramComparisonCalculationModule extends BaseCalculationModule { | |
constructor( | |
private algoliaService: AlgoliaService, | |
private dataService: DataService | |
) { | |
super(); | |
} | |
// Override to define search-specific data needs | |
protected async enrichInputs(inputs: InputData, dataService: DataService): Promise<InputData> { | |
let enrichedInputs = { ...inputs }; | |
// This module needs to search programs | |
const programs = await dataService.searchPrograms({ | |
degree: inputs.degree, | |
category: inputs.category, | |
subject: inputs.subject, | |
accreditation: inputs.accreditation, | |
startDate: inputs.startDate | |
}); | |
// Add search results to inputs | |
enrichedInputs.searchResults = programs; | |
return enrichedInputs; | |
} | |
async calculate(inputs: InputData, config: CalculatorConfig): Promise<CalculationResult> { | |
const { degree, category, subject, accreditation, startDate } = inputs; | |
// Build search filters for Algolia | |
const searchFilters = this.buildSearchFilters({ | |
degree, | |
category, | |
subject, | |
accreditation, | |
startDate | |
}); | |
// Search programs in Algolia | |
const programs = await this.algoliaService.searchPrograms(searchFilters); | |
// Enhance with additional data if needed | |
const enrichedPrograms = await this.enrichProgramData(programs); | |
// Rank by relevance score | |
const rankedPrograms = this.rankPrograms(enrichedPrograms, inputs); | |
return { | |
matchingPrograms: rankedPrograms, | |
totalMatches: programs.length, | |
searchCriteria: inputs, | |
comparisonFeatures: this.getComparisonFeatures(), | |
metadata: { | |
searchPerformed: new Date(), | |
filtersApplied: Object.keys(inputs).filter(key => inputs[key]), | |
algorithmVersion: '1.0' | |
} | |
}; | |
} | |
private buildSearchFilters(criteria: any): AlgoliaSearchParams { | |
const filters: string[] = []; | |
const facetFilters: string[][] = []; | |
// Degree level filters | |
if (criteria.degree?.length > 0) { | |
facetFilters.push(criteria.degree.map(d => `degreeLevel:${d}`)); | |
} | |
// Category filters | |
if (criteria.category?.length > 0) { | |
facetFilters.push(criteria.category.map(c => `category:${c}`)); | |
} | |
// Subject area | |
if (criteria.subject) { | |
filters.push(`subjectArea:"${criteria.subject}"`); | |
} | |
// Accreditation | |
if (criteria.accreditation === 'yes') { | |
filters.push('accredited:true'); | |
} else if (criteria.accreditation === 'no') { | |
filters.push('accredited:false'); | |
} | |
// Start date availability | |
if (criteria.startDate !== 'either') { | |
const startMapping = { | |
'ASAP': 'immediate', | |
'3-months': 'quarterly', | |
'6-months': 'biannual', | |
'1-year': 'annual' | |
}; | |
filters.push(`startOptions:${startMapping[criteria.startDate]}`); | |
} | |
return { | |
filters: filters.join(' AND '), | |
facetFilters, | |
hitsPerPage: 50, | |
attributesToRetrieve: [ | |
'name', 'schoolName', 'degreeLevel', 'category', 'subjectArea', | |
'tuitionCost', 'duration', 'accredited', 'startDates', 'location', | |
'deliveryMethod', 'creditHours', 'prerequisites' | |
] | |
}; | |
} | |
private async enrichProgramData(programs: any[]): Promise<Program[]> { | |
return Promise.all(programs.map(async (program) => { | |
// Add calculated fields | |
const costPerCredit = program.tuitionCost / (program.creditHours || 120); | |
const timeToCompletion = this.calculateTimeToCompletion(program); | |
return { | |
...program, | |
costPerCredit, | |
timeToCompletion, | |
affordabilityScore: this.calculateAffordabilityScore(program), | |
popularityRank: await this.getPopularityRank(program.id) | |
}; | |
})); | |
} | |
private rankPrograms(programs: Program[], criteria: any): RankedProgram[] { | |
return programs.map(program => ({ | |
...program, | |
relevanceScore: this.calculateRelevanceScore(program, criteria) | |
})).sort((a, b) => b.relevanceScore - a.relevanceScore); | |
} | |
private getComparisonFeatures(): ComparisonFeature[] { | |
return [ | |
{ field: 'tuitionCost', label: 'Tuition Cost', type: 'currency', highlight: 'lower' }, | |
{ field: 'duration', label: 'Program Duration', type: 'duration', highlight: 'shorter' }, | |
{ field: 'creditHours', label: 'Credit Hours', type: 'number', highlight: 'none' }, | |
{ field: 'deliveryMethod', label: 'Delivery Method', type: 'text', highlight: 'none' }, | |
{ field: 'accredited', label: 'Accredited', type: 'boolean', highlight: 'true' }, | |
{ field: 'costPerCredit', label: 'Cost per Credit', type: 'currency', highlight: 'lower' }, | |
{ field: 'timeToCompletion', label: 'Time to Complete', type: 'duration', highlight: 'shorter' } | |
]; | |
} | |
validate(inputs: InputData, config: CalculatorConfig): ValidationResult { | |
const errors: ValidationError[] = []; | |
// At least one filter must be selected | |
const hasFilters = Object.values(inputs).some(value => | |
value && (Array.isArray(value) ? value.length > 0 : true) | |
); | |
if (!hasFilters) { | |
errors.push({ | |
field: 'general', | |
message: 'Please select at least one filter to search programs', | |
code: 'NO_FILTERS_SELECTED' | |
}); | |
} | |
return { | |
isValid: errors.length === 0, | |
errors | |
}; | |
} | |
getRequiredFields(): string[] { | |
return []; // No strictly required fields - user can filter by any combination | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// A few Type definitions | |
// Base interface for all calculator inputs | |
interface InputData { | |
[key: string]: string | number | boolean | string[] | undefined; | |
programId?: string; // for program-based calculations | |
} | |
// More specific interfaces for type safety | |
interface ProgramLengthInputs extends InputData { | |
programId: string; | |
classesPerSemester: number; | |
} | |
interface CostCalculatorInputs extends InputData { | |
programId?: string; | |
studyPace: string; | |
financialAid?: number; | |
income?: number; | |
costOfLiving?: number; | |
} | |
interface CareerPathwaysCalculatorInputs extends InputData { | |
programId: string; | |
currentSalary: number; | |
targetSalary: number; | |
desiredCareer: string; | |
jobMarketData?: { [key: string]: any; }; | |
} | |
// For the DataService pattern: | |
// these come from the data source now, not the input config | |
interface ProgramData { | |
id: string; | |
degreeType: 'associates' | 'bachelors' | 'masters' | 'doctorate'; | |
totalCredits: number; | |
prerequisiteCredits: number; | |
schoolId: string; | |
isActive: boolean; | |
name?: string; | |
duration?: number; | |
} | |
interface SchoolData { | |
id: string; | |
name: string; | |
location: string; | |
type: string; | |
} | |
interface DataSource { | |
getProgram(programId: string): Promise<ProgramData>; | |
// Future methods: | |
// getSchool(schoolId: string): Promise<SchoolData>; | |
// searchPrograms(query: SearchQuery): Promise<ProgramData[]>; | |
} | |
interface CalculationModule { | |
calculate(inputs: InputData, config: CalculatorConfig): Promise<CalculationResult>; | |
validate(inputs: InputData, config: CalculatorConfig): ValidationResult; | |
} | |
interface CalculationResult { | |
pathways?: Pathway[]; | |
metadata?: { | |
totalCredits: number; | |
selectedClasses: number; | |
calculatedAt: Date; | |
}; | |
[key: string]: any; | |
} | |
interface ValidationResult { | |
isValid: boolean; | |
errors: ValidationError[]; | |
} | |
interface ValidationError { | |
field: string; | |
message: string; | |
code: string; | |
} | |
interface InputField { | |
name: string; | |
type: 'select' | 'slider' | 'text' | 'number'; | |
required: boolean; | |
options?: string[]; | |
min?: number; | |
max?: number; | |
default?: any; | |
validation?: ValidationRule[]; | |
} | |
interface Pathway { | |
type: string; | |
duration: number; | |
classesPerSemester: number; | |
weeklyStudyHours: number; | |
description: string; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Career Pathways specific | |
interface CareerPathwayInputs { | |
currentEducation: string; | |
fieldOfStudy: string; | |
currentRole: string; | |
targetRole: string; | |
} | |
interface CareerPathwayResponse { | |
steps: CareerStep[]; | |
salaryData: SalaryData; | |
confidence: number; | |
alternativePathways?: AlternativePathway[]; | |
} | |
interface CalculationResult { | |
careerPathway: { | |
currentRole: string; | |
targetRole: string; | |
steps: PathwayStep[]; | |
totalTimeEstimate: string; | |
avgSalaryIncrease: string; | |
}; | |
alternativePathways?: AlternativePathway[]; | |
metadata: { | |
fieldOfStudy: string; | |
currentEducation: string; | |
calculatedAt: Date; | |
confidence: number; | |
}; | |
} | |
interface PathwayStep { | |
stepNumber: number; | |
title: string; | |
duration: string; | |
description: string; | |
keySkills: string[]; | |
requirements?: string[]; | |
recommendations?: string[]; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
interface SearchCriteria { | |
degree?: string[]; | |
category?: string[]; | |
subject?: string; | |
accreditation?: string; | |
startDate?: string; | |
location?: string; | |
maxCost?: number; | |
deliveryMethod?: string[]; | |
} | |
interface AlgoliaSearchParams { | |
filters: string; | |
facetFilters: string[][]; | |
hitsPerPage: number; | |
attributesToRetrieve: string[]; | |
} | |
interface Program { | |
id: string; | |
name: string; | |
schoolName: string; | |
degreeLevel: string; | |
category: string; | |
subjectArea: string; | |
tuitionCost: number; | |
duration: string; | |
accredited: boolean; | |
startDates: string[]; | |
location: string; | |
deliveryMethod: string; | |
creditHours: number; | |
prerequisites: string[]; | |
} | |
interface RankedProgram extends Program { | |
relevanceScore: number; | |
costPerCredit: number; | |
timeToCompletion: string; | |
affordabilityScore: number; | |
popularityRank: number; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# Configuration | |
# File system example: | |
# /configs/ | |
# program-length.yaml # Calculator configurations | |
# cost-calculator.yaml # Future configs | |
# Calculator configuration for a program length calculator | |
# program-length.yaml | |
calculator: | |
id: "program-length" | |
name: "Program Length Calculator" | |
module: "ProgramLengthCalculationModule" | |
inputs: | |
# Context inputs (automatically fetched if provided) | |
- name: "programId" | |
type: "context" | |
source: "algolia" | |
fields: ["degreeType", "totalCredits", "prerequisites"] | |
- name: "classesPerSemester" | |
type: "slider" | |
required: true | |
min: 1 | |
max: 6 | |
default: 3 | |
validation: | |
- rule: "range:1,6" | |
- name: "studyPace" | |
type: "select" | |
options: ["full-time", "part-time", "accelerated"] | |
outputs: | |
- name: "pathways" | |
type: "array" | |
format: "pathway_grid" | |
- name: "studyTimeEstimate" | |
type: "number" | |
format: "hours_per_week" | |
caching: | |
ttl: 3600 | |
strategy: "inputs_hash" | |
analytics: | |
events: | |
- "calculator_started" | |
- "inputs_changed" | |
- "results_calculated" | |
- "pathway_selected" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# career-pathways.yaml | |
calculator: | |
id: "career-pathways" | |
name: "Career Pathways Calculator" | |
description: "Discover your path from current role to dream career" | |
inputs: | |
- name: "currentEducation" | |
type: "select" | |
label: "Current Education Level" | |
required: true | |
options: | |
- value: "high-school" | |
label: "High School Diploma" | |
- value: "associates" | |
label: "Associate's Degree" | |
- value: "bachelors" | |
label: "Bachelor's Degree" | |
- value: "masters" | |
label: "Master's Degree" | |
- value: "doctorate" | |
label: "Doctorate/PhD" | |
- name: "fieldOfStudy" | |
type: "select" | |
label: "Field of Study" | |
required: true | |
options: | |
- value: "business" | |
label: "Business" | |
- value: "computer-science" | |
label: "Computer Science" | |
- value: "marketing" | |
label: "Marketing" | |
- value: "engineering" | |
label: "Engineering" | |
# ... more options | |
- name: "currentRole" | |
type: "autocomplete" | |
label: "Current Role" | |
required: true | |
searchable: true | |
# Could connect to job title API | |
- name: "targetRole" | |
type: "autocomplete" | |
label: "Target Role" | |
required: true | |
searchable: true | |
outputs: | |
- name: "careerPathway" | |
type: "pathway_timeline" | |
template: "career_steps" | |
- name: "alternativePathways" | |
type: "pathway_cards" | |
template: "alternative_options" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# program-comparison.yaml | |
calculator: | |
id: "program-comparison" | |
name: "Program Comparison Tool" | |
module: "ProgramComparisonCalculationModule" | |
inputs: | |
- name: "degree" | |
type: "multi-select" | |
label: "Degree Level" | |
options: ["associates", "bachelors", "masters", "doctorate"] | |
- name: "category" | |
type: "multi-select" | |
label: "Field Category" | |
options: ["STEM", "humanities", "business", "healthcare", "arts"] | |
- name: "subject" | |
type: "autocomplete" | |
label: "Subject Area" | |
searchable: true | |
source: "algolia_subjects" | |
- name: "accreditation" | |
type: "select" | |
label: "Accreditation Required" | |
options: ["yes", "no", "either"] | |
- name: "startDate" | |
type: "select" | |
label: "When to Start" | |
options: ["ASAP", "3-months", "6-months", "1-year"] | |
outputs: | |
- name: "matchingPrograms" | |
type: "program_list" | |
template: "filterable_grid" | |
maxResults: 50 | |
- name: "selectedComparison" | |
type: "comparison_table" | |
template: "side_by_side" | |
maxCompare: 2 | |
caching: | |
ttl: 1800 # 30 minutes | |
strategy: "filter_hash" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# configs/affordability.yaml | |
# Includes notes about how we could allow some business edits in future | |
calculator: | |
id: "affordability" # LOCKED - developers only | |
name: "Affordability Calculator" # ✅ BUSINESS CAN EDIT | |
description: "Calculate education affordability" # ✅ BUSINESS CAN EDIT | |
module: "AffordabilityCalculationModule" # LOCKED | |
# Business-editable section | |
display: | |
title: "Can You Afford This Education?" # ✅ BUSINESS CAN EDIT | |
subtitle: "Get personalized affordability insights" # ✅ BUSINESS CAN EDIT | |
helpText: "This calculator helps you understand..." # ✅ BUSINESS CAN EDIT | |
inputs: | |
- name: "income" # LOCKED | |
type: "number" # LOCKED | |
label: "Annual Income" # ✅ BUSINESS CAN EDIT | |
helpText: "Enter your total yearly income before taxes" # ✅ BUSINESS CAN EDIT | |
required: true # LOCKED | |
min: 0 # LOCKED | |
max: 500000 # ✅ BUSINESS CAN EDIT (with limits) | |
# LOCKED section - developers only | |
validation: | |
rules: [] | |
caching: | |
ttl: 3600 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Connect the frontend to the backend with an API interface for the calculator service | |
/** File system example: | |
/api/ | |
/calculators/ | |
/[calculatorId]/ | |
/config/route.ts # GET configuration | |
/calculate/route.ts # POST calculation | |
/validate/route.ts # POST validation | |
*/ | |
class CalculatorAPI { | |
private baseURL: string; | |
constructor(baseURL: string) { | |
this.baseURL = baseURL; | |
} | |
// Get calculator configuration (loads from the configs) | |
// Makes HTTP requests from the frontend to fetch calculator configurations | |
// Used by: The CalculatorSystem React component to load configuration when it mounts | |
async getConfiguration(calculatorId: string): Promise<CalculatorConfig> { | |
const response = await fetch(`${this.baseURL}/calculators/${calculatorId}/config`); | |
if (!response.ok) { | |
throw new Error(`Failed to load configuration for ${calculatorId}`); | |
} | |
return response.json(); | |
} | |
// Execute calculation (calls CalculatorEngineService.execute) | |
async execute(calculatorId: string, inputs: InputData): Promise<CalculationResult> { | |
const response = await fetch(`${this.baseURL}/calculators/${calculatorId}/calculate`, { | |
method: 'POST', | |
headers: { | |
'Content-Type': 'application/json', | |
}, | |
body: JSON.stringify({ inputs }), | |
}); | |
if (!response.ok) { | |
const error = await response.json(); | |
if (response.status === 400) { | |
throw new ValidationError(error.errors); | |
} | |
throw new Error('Calculation failed'); | |
} | |
return response.json(); | |
} | |
async executeForProgram( | |
calculatorId: string, | |
programId: string, | |
userInputs: InputData | |
): Promise<CalculationResult> { | |
return this.execute(calculatorId, { programId, ...userInputs }); | |
} | |
// Validate inputs (calls CalculatorEngineService.validate) | |
async validate(calculatorId: string, inputs: InputData): Promise<ValidationResult> { | |
const response = await fetch(`${this.baseURL}/calculators/${calculatorId}/validate`, { | |
method: 'POST', | |
headers: { | |
'Content-Type': 'application/json', | |
}, | |
body: JSON.stringify({ inputs }), | |
}); | |
return response.json(); | |
} | |
} | |
export const calculatorAPI = new CalculatorAPI(process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api'); | |
// Backend API Endpoints that delegate to the CalculatorEngineService: | |
import { calculatorEngineService } from '@/services/calculatorEngineService'; | |
export async function GET( | |
request: Request, | |
{ params }: { params: { calculatorId: string } } | |
) { | |
try { | |
const calculatorId = params.calculatorId; | |
// This calls the backend CalculatorEngineService.getConfiguration() | |
const config = calculatorEngineService.getConfiguration(calculatorId); | |
return Response.json(config); | |
} catch (error) { | |
if (error.message.includes('No configuration found')) { | |
return Response.json( | |
{ error: `Calculator ${params.calculatorId} not found` }, | |
{ status: 404 } | |
); | |
} | |
return Response.json( | |
{ error: 'Internal server error' }, | |
{ status: 500 } | |
); | |
} | |
} | |
// handles requests to /api/calculators/[calculatorId]/calculate and orchestrates the entire calculation process | |
// bridge bteween the CalculatorSystem React component and the backend CalculatorEngineService and calculator modules | |
// going through this API layer provides a clean separation of concerns for error handling, validation, and execution | |
export async function POST( | |
request: Request, | |
{ params }: { params: { calculatorId: string } } | |
) { | |
try { | |
// parse the request body to get the inputs for the calculation | |
const { inputs } = await request.json(); | |
// extract the calculatorId from the request parameters | |
const calculatorId = params.calculatorId; | |
// delegate the calculation to the CalculatorEngineService | |
// This calls the backend CalculatorEngineService.execute() | |
const result = await calculatorEngineService.execute(calculatorId, inputs); | |
return Response.json(result); | |
} catch (error) { | |
if (error instanceof ValidationError) { | |
return Response.json( | |
{ errors: error.errors }, | |
{ status: 400 } | |
); | |
} | |
return Response.json( | |
{ error: 'Internal server error' }, | |
{ status: 500 } | |
); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Frontend usage examples | |
/** File system example: | |
* /components/ | |
CalculatorSystem.tsx # Universal component | |
/calculator-forms/ | |
CalculatorForm.tsx # Dynamic form renderer | |
CalculatorResults.tsx # Results display | |
*/ | |
// File: CalculatorSystem.tsx | |
import React, { useState, useEffect } from 'react'; | |
import { calculatorAPI } from './calculatorAPI'; | |
import { CalculatorForm } from './CalculatorForm'; | |
import { CalculatorResults } from './CalculatorResults'; | |
import { CalculationResult, CalculatorConfig, InputData, ValidationError } from './types'; | |
interface CalculatorSystemProps { | |
configId: string; | |
variant?: 'compact' | 'full' | 'embedded'; | |
onComplete?: (result: CalculationResult) => void; | |
userId?: string; | |
} | |
const CalculatorSystem: React.FC<CalculatorSystemProps> = ({ | |
configId, | |
variant = 'full', | |
onComplete | |
}) => { | |
const [config, setConfig] = useState<CalculatorConfig | null>(null); | |
const [inputs, setInputs] = useState<InputData>({}); | |
const [result, setResult] = useState<CalculationResult | null>(null); | |
const [loading, setLoading] = useState(false); | |
const [errors, setErrors] = useState<ValidationError[]>([]); | |
// Load calculator configuration on mount | |
useEffect(() => { | |
async function loadConfig() { | |
try { | |
const calculatorConfig = await calculatorAPI.getConfiguration(configId); | |
setConfig(calculatorConfig); | |
// Initialize default input values | |
const defaultInputs = calculatorConfig.inputs.reduce((acc, input) => { | |
if (input.default) { | |
acc[input.name] = input.default; | |
} | |
return acc; | |
}, {} as InputData); | |
setInputs(defaultInputs); | |
} catch (error) { | |
console.error('Failed to load calculator config:', error); | |
} | |
} | |
loadConfig(); | |
}, [configId]); | |
// Handle calculation | |
const handleCalculate = async () => { | |
if (!config) return; | |
setLoading(true); | |
setErrors([]); | |
try { | |
// This calls the CalculatorEngineService.execute() | |
// NOTE: Backend will handle data fetching for programId and user-provided inputs, e.g. | |
// { programId, classesPerSemester: 4 } | |
const calculationResult = await calculatorAPI.execute(configId, inputs); | |
setResult(calculationResult); | |
// Trigger completion callback (e.g., route to marketplace) | |
if (onComplete) { | |
onComplete(calculationResult); | |
} | |
} catch (error) { | |
if (error instanceof ValidationError) { | |
setErrors(error.errors); | |
} else { | |
console.error('Calculation failed:', error); | |
} | |
} finally { | |
setLoading(false); | |
} | |
}; | |
// Handle input changes with real-time validation | |
const handleInputChange = async (fieldName: string, value: any) => { | |
const newInputs = { ...inputs, [fieldName]: value }; | |
setInputs(newInputs); | |
// Clear previous errors when user makes changes | |
setErrors([]); | |
// Optional: Could implement client-side validation here if desired | |
// const clientValidation = validateInputsLocally(newInputs, config); | |
// setErrors(clientValidation.errors); | |
}; | |
if (!config) { | |
return <div>Loading calculator...</div>; | |
} | |
return ( | |
<div className={`calculator-system calculator-system--${variant}`}> | |
<CalculatorForm | |
config={config} | |
inputs={inputs} | |
errors={errors} | |
onChange={handleInputChange} | |
onSubmit={handleCalculate} | |
loading={loading} | |
/> | |
{result && ( | |
<CalculatorResults | |
config={config} | |
result={result} | |
onActionClick={(action) => handleResultAction(action, result)} | |
/> | |
)} | |
</div> | |
); | |
}; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Pathway cards for Program Length Calculator | |
<ResultsGrid template="pathway_cards"> | |
{pathways.map(pathway => ( | |
<PathwayCard key={pathway.type}> | |
<CardHeader>{pathway.type}</CardHeader> | |
<DataPoint label="Duration">{pathway.duration} years</DataPoint> | |
<DataPoint label="Classes/Semester">{pathway.classesPerSemester}</DataPoint> | |
<DataPoint label="Study Hours/Week">{pathway.weeklyStudyHours}</DataPoint> | |
<Description>{pathway.description}</Description> | |
</PathwayCard> | |
))} | |
</ResultsGrid> | |
// Cost calculator might use different template | |
<ResultsGrid template="cost_breakdown"> | |
<CostSummary totalCost={result.totalCost} /> | |
<PaymentOptions options={result.paymentPlans} /> | |
</ResultsGrid> | |
// Career Pathway Result example | |
// The results would render using the configurable template system | |
<ResultsGrid template="career_steps"> | |
<CurrentRoleCard role={result.careerPathway.currentRole} /> | |
{result.careerPathway.steps.map(step => ( | |
<PathwayStepCard key={step.stepNumber} step={step} /> | |
))} | |
<TargetRoleCard | |
role={result.careerPathway.targetRole} | |
timeEstimate={result.careerPathway.totalTimeEstimate} | |
salaryIncrease={result.careerPathway.avgSalaryIncrease} | |
/> | |
</ResultsGrid> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// For the program length calculator page | |
export default function DegreePathwayCalculatorPage() { | |
const handleCalculationComplete = (result: CalculationResult) => { | |
// Route to StudyMatch with calculated pathway | |
router.push(`/goToStudyMatch?pathway=${result.selectedPathway}&credits=${result.totalCredits}`); | |
}; | |
return ( | |
<div> | |
<h1>Degree Completion Pathway Calculator</h1> | |
<CalculatorSystem | |
configId="program-length" | |
variant="full" | |
onComplete={handleCalculationComplete} | |
/> | |
</div> | |
); | |
} | |
// For embedded use in landing pages | |
export default function EmbeddedCalculator() { | |
return ( | |
<CalculatorSystem | |
configId="program-length" | |
variant="compact" | |
onComplete={(result) => { | |
// Track conversion event | |
analytics.track('calculator_completed', { | |
calculatorType: 'program-length', | |
pathwaySelected: result.selectedPathway | |
}); | |
}} | |
/> | |
); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment