Skip to content

Instantly share code, notes, and snippets.

@germanny
Last active June 25, 2025 21:40
Show Gist options
  • Save germanny/dbfbd9c4634015ca21ec309736771353 to your computer and use it in GitHub Desktop.
Save germanny/dbfbd9c4634015ca21ec309736771353 to your computer and use it in GitHub Desktop.
Calculator Architecture System
// 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';
}
}
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');
}
}
}
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: [] };
}
}
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;
}
}
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;
}
}
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
}
}
}
// 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'];
}
}
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
}
}
// 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;
}
// 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[];
}
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;
}
# 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"
# 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"
# 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"
# 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
// 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 }
);
}
}
// 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>
);
};
// 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>
// 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