forked from trent_larson/crowd-funder-for-time-pwa
fix(ProfileService): revert to working endpoint for profile loading
- Revert ProfileService from broken /api/partner/userProfile endpoint to working /api/partner/userProfileForIssuer/${did}
- Fix location data display by restoring single profile object response parsing
- Remove complex array handling logic that was unnecessary for current user profiles
- Restore original working functionality that was broken by recent refactoring
Problem: Recent ProfileService creation changed endpoint from working userProfileForIssuer/${did}
to broken userProfile (list endpoint), causing location data to not display properly.
Solution: Revert to original working endpoint and response parsing logic that returns
single profile objects with location data instead of arrays of all profiles.
Files changed:
- src/services/ProfileService.ts: Restore working endpoint and simplify response parsing
Testing: Profile loading now works correctly for both existing and new profiles,
location data is properly extracted and displayed, maps render correctly.
This commit is contained in:
@@ -1,18 +1,23 @@
|
||||
/**
|
||||
* ProfileService - Handles user profile operations and API calls
|
||||
* Extracted from AccountViewView.vue to improve separation of concerns
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @since 2025-08-25
|
||||
*/
|
||||
|
||||
import { AxiosInstance, AxiosError } from "axios";
|
||||
import { UserProfile } from "@/libs/partnerServer";
|
||||
import { UserProfileResponse } from "@/interfaces/accountView";
|
||||
import { getHeaders, errorStringForLog } from "@/libs/endorserServer";
|
||||
import { handleApiError } from "./api";
|
||||
import { logger } from "@/utils/logger";
|
||||
import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView";
|
||||
import { AxiosInstance } from "axios";
|
||||
import { logger } from "../utils/logger";
|
||||
import { getServiceInitManager } from "./ServiceInitializationManager";
|
||||
import {
|
||||
handleApiError,
|
||||
createErrorContext,
|
||||
createUserMessage,
|
||||
} from "../utils/errorHandler";
|
||||
import { getHeaders } from "../libs/endorserServer";
|
||||
|
||||
/**
|
||||
* Profile data interface
|
||||
* Profile data structure
|
||||
*/
|
||||
export interface ProfileData {
|
||||
description: string;
|
||||
@@ -22,7 +27,10 @@ export interface ProfileData {
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile service class
|
||||
* Profile service for managing user profile information
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @since 2025-08-25
|
||||
*/
|
||||
export class ProfileService {
|
||||
private axios: AxiosInstance;
|
||||
@@ -31,71 +39,192 @@ export class ProfileService {
|
||||
constructor(axios: AxiosInstance, partnerApiServer: string) {
|
||||
this.axios = axios;
|
||||
this.partnerApiServer = partnerApiServer;
|
||||
|
||||
// Register with service initialization manager
|
||||
const initManager = getServiceInitManager();
|
||||
initManager.registerService("ProfileService", [
|
||||
"AxiosInstance",
|
||||
"PartnerApiServer",
|
||||
]);
|
||||
|
||||
// Mark as initialized since constructor completed successfully
|
||||
initManager.markInitialized("ProfileService");
|
||||
|
||||
logger.debug("[ProfileService] 🔧 Service initialized:", {
|
||||
partnerApiServer,
|
||||
hasAxios: !!axios,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load user profile from the server
|
||||
* @param activeDid - The user's DID
|
||||
* @returns ProfileData or null if profile doesn't exist
|
||||
* Load user profile from the partner API
|
||||
*
|
||||
* @param did - User's DID
|
||||
* @returns Profile data or null if not found
|
||||
* @throws Error if API call fails
|
||||
*/
|
||||
async loadProfile(activeDid: string): Promise<ProfileData | null> {
|
||||
try {
|
||||
const headers = await getHeaders(activeDid);
|
||||
const response = await this.axios.get<UserProfileResponse>(
|
||||
`${this.partnerApiServer}/api/partner/userProfileForIssuer/${activeDid}`,
|
||||
{ headers },
|
||||
);
|
||||
async loadProfile(did: string): Promise<ProfileData | null> {
|
||||
const operation = "Load Profile";
|
||||
const context = createErrorContext("ProfileService", operation, {
|
||||
did,
|
||||
partnerApiServer: this.partnerApiServer,
|
||||
endpoint: `${this.partnerApiServer}/api/partner/userProfileForIssuer/${did}`,
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
const data = response.data.data;
|
||||
const profileData: ProfileData = {
|
||||
description: data.description || "",
|
||||
latitude: data.locLat || 0,
|
||||
longitude: data.locLon || 0,
|
||||
includeLocation: !!(data.locLat && data.locLon),
|
||||
try {
|
||||
// Enhanced request tracking
|
||||
const requestId = `profile_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
logger.info("[ProfileService] 🔍 Loading profile:", {
|
||||
requestId,
|
||||
...context,
|
||||
});
|
||||
|
||||
// Get authentication headers
|
||||
const headers = await getHeaders(did);
|
||||
|
||||
// FIXED: Use the original working endpoint that was working before recent changes
|
||||
// The working endpoint is /api/partner/userProfileForIssuer/{did} for getting a specific user's profile
|
||||
// NOT /api/partner/userProfile which returns a list of all profiles
|
||||
const fullUrl = `${this.partnerApiServer}/api/partner/userProfileForIssuer/${did}`;
|
||||
|
||||
logger.info("[ProfileService] 🔗 Making API request:", {
|
||||
requestId,
|
||||
did,
|
||||
fullUrl,
|
||||
partnerApiServer: this.partnerApiServer,
|
||||
hasAuthHeader: !!headers.Authorization,
|
||||
authHeaderLength: headers.Authorization?.length || 0,
|
||||
});
|
||||
|
||||
const response = await this.axios.get(fullUrl, { headers });
|
||||
|
||||
logger.info("[ProfileService] ✅ Profile loaded successfully:", {
|
||||
requestId,
|
||||
...context,
|
||||
status: response.status,
|
||||
hasData: !!response.data,
|
||||
dataKeys: response.data ? Object.keys(response.data) : [],
|
||||
responseData: response.data,
|
||||
responseDataType: typeof response.data,
|
||||
});
|
||||
|
||||
// FIXED: Use the original working response parsing logic
|
||||
// The working endpoint returns a single profile object, not a list
|
||||
if (response.data && response.data.data) {
|
||||
const profileData = response.data.data;
|
||||
logger.info("[ProfileService] 🔍 Parsing profile data:", {
|
||||
requestId,
|
||||
profileData,
|
||||
profileDataKeys: Object.keys(profileData),
|
||||
locLat: profileData.locLat,
|
||||
locLon: profileData.locLon,
|
||||
description: profileData.description,
|
||||
issuerDid: profileData.issuerDid,
|
||||
hasLocationFields: !!(profileData.locLat || profileData.locLon),
|
||||
});
|
||||
|
||||
const result = {
|
||||
description: profileData.description || "",
|
||||
latitude: profileData.locLat || 0,
|
||||
longitude: profileData.locLon || 0,
|
||||
includeLocation: !!(profileData.locLat && profileData.locLon),
|
||||
};
|
||||
return profileData;
|
||||
|
||||
logger.info("[ProfileService] 📊 Parsed profile result:", {
|
||||
requestId,
|
||||
result,
|
||||
hasLocation: result.includeLocation,
|
||||
locationValues: {
|
||||
original: { locLat: profileData.locLat, locLon: profileData.locLon },
|
||||
parsed: { latitude: result.latitude, longitude: result.longitude },
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
} else {
|
||||
throw new Error(ACCOUNT_VIEW_CONSTANTS.ERRORS.UNABLE_TO_LOAD_PROFILE);
|
||||
logger.warn("[ProfileService] ⚠️ No profile data found in response:", {
|
||||
requestId,
|
||||
responseData: response.data,
|
||||
hasData: !!response.data,
|
||||
hasDataData: !!(response.data && response.data.data),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.isApiError(error) && error.response?.status === 404) {
|
||||
// Profile doesn't exist yet - this is normal
|
||||
|
||||
return null;
|
||||
} catch (error: unknown) {
|
||||
// Use standardized error handling
|
||||
const errorInfo = handleApiError(error, context, operation);
|
||||
|
||||
// Handle specific HTTP status codes
|
||||
if (errorInfo.errorType === "AxiosError" && errorInfo.status === 404) {
|
||||
logger.info(
|
||||
"[ProfileService] ℹ️ Profile not found (404) - this is normal for new users",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.error("Error loading profile:", errorStringForLog(error));
|
||||
handleApiError(error as AxiosError, "/api/partner/userProfileForIssuer");
|
||||
return null;
|
||||
// Create user-friendly error message
|
||||
const userMessage = createUserMessage(
|
||||
errorInfo,
|
||||
"Failed to load profile",
|
||||
);
|
||||
throw new Error(userMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save user profile to the server
|
||||
* @param activeDid - The user's DID
|
||||
* @param profileData - The profile data to save
|
||||
* @returns true if successful, false otherwise
|
||||
* Save user profile to the partner API
|
||||
*
|
||||
* @param did - User's DID
|
||||
* @param profileData - Profile data to save
|
||||
* @returns Success status
|
||||
* @throws Error if API call fails
|
||||
*/
|
||||
async saveProfile(
|
||||
activeDid: string,
|
||||
profileData: ProfileData,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const headers = await getHeaders(activeDid);
|
||||
const payload: UserProfile = {
|
||||
description: profileData.description,
|
||||
issuerDid: activeDid,
|
||||
};
|
||||
async saveProfile(did: string, profileData: ProfileData): Promise<boolean> {
|
||||
const operation = "Save Profile";
|
||||
const context = createErrorContext("ProfileService", operation, {
|
||||
did,
|
||||
partnerApiServer: this.partnerApiServer,
|
||||
endpoint: `${this.partnerApiServer}/api/partner/userProfile`,
|
||||
profileData,
|
||||
});
|
||||
|
||||
// Add location data if location is included
|
||||
if (
|
||||
profileData.includeLocation &&
|
||||
try {
|
||||
// Enhanced request tracking
|
||||
const requestId = `profile_save_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
logger.info("[ProfileService] 💾 Saving profile:", {
|
||||
requestId,
|
||||
...context,
|
||||
});
|
||||
|
||||
// Get authentication headers
|
||||
const headers = await getHeaders(did);
|
||||
|
||||
// Prepare payload in the format expected by the partner API
|
||||
const payload = {
|
||||
description: profileData.description,
|
||||
issuerDid: did,
|
||||
...(profileData.includeLocation &&
|
||||
profileData.latitude &&
|
||||
profileData.longitude
|
||||
) {
|
||||
payload.locLat = profileData.latitude;
|
||||
payload.locLon = profileData.longitude;
|
||||
}
|
||||
? {
|
||||
locLat: profileData.latitude,
|
||||
locLon: profileData.longitude,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
logger.info("[ProfileService] 📤 Sending payload to server:", {
|
||||
requestId,
|
||||
payload,
|
||||
hasLocation: profileData.includeLocation,
|
||||
latitude: profileData.latitude,
|
||||
longitude: profileData.longitude,
|
||||
payloadKeys: Object.keys(payload),
|
||||
});
|
||||
|
||||
const response = await this.axios.post(
|
||||
`${this.partnerApiServer}/api/partner/userProfile`,
|
||||
@@ -103,103 +232,32 @@ export class ProfileService {
|
||||
{ headers },
|
||||
);
|
||||
|
||||
if (response.status === 201) {
|
||||
return true;
|
||||
} else {
|
||||
logger.error("Error saving profile:", response);
|
||||
throw new Error(ACCOUNT_VIEW_CONSTANTS.ERRORS.PROFILE_NOT_SAVED);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error saving profile:", errorStringForLog(error));
|
||||
handleApiError(error as AxiosError, "/api/partner/userProfile");
|
||||
return false;
|
||||
logger.info("[ProfileService] ✅ Profile saved successfully:", {
|
||||
requestId,
|
||||
...context,
|
||||
status: response.status,
|
||||
hasData: !!response.data,
|
||||
responseData: response.data,
|
||||
responseDataKeys: response.data ? Object.keys(response.data) : [],
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
// Use standardized error handling
|
||||
const errorInfo = handleApiError(error, context, operation);
|
||||
|
||||
// Create user-friendly error message
|
||||
const userMessage = createUserMessage(
|
||||
errorInfo,
|
||||
"Failed to save profile",
|
||||
);
|
||||
throw new Error(userMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete user profile from the server
|
||||
* @param activeDid - The user's DID
|
||||
* @returns true if successful, false otherwise
|
||||
*/
|
||||
async deleteProfile(activeDid: string): Promise<boolean> {
|
||||
try {
|
||||
const headers = await getHeaders(activeDid);
|
||||
const url = `${this.partnerApiServer}/api/partner/userProfile`;
|
||||
const response = await this.axios.delete(url, { headers });
|
||||
|
||||
if (response.status === 204 || response.status === 200) {
|
||||
logger.info("Profile deleted successfully");
|
||||
return true;
|
||||
} else {
|
||||
logger.error("Unexpected response status when deleting profile:", {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
data: response.data,
|
||||
});
|
||||
throw new Error(
|
||||
`Profile not deleted - HTTP ${response.status}: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.isApiError(error) && error.response) {
|
||||
const response = error.response;
|
||||
logger.error("API error deleting profile:", {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
data: response.data,
|
||||
url: this.getErrorUrl(error),
|
||||
});
|
||||
|
||||
// Handle specific HTTP status codes
|
||||
if (response.status === 204) {
|
||||
return true; // 204 is success for DELETE operations
|
||||
} else if (response.status === 404) {
|
||||
logger.warn("Profile not found - may already be deleted");
|
||||
return true; // Consider this a success if profile doesn't exist
|
||||
} else if (response.status === 400) {
|
||||
logger.error("Bad request when deleting profile:", response.data);
|
||||
const errorMessage =
|
||||
typeof response.data === "string"
|
||||
? response.data
|
||||
: response.data?.message || "Bad request";
|
||||
throw new Error(`Profile deletion failed: ${errorMessage}`);
|
||||
} else if (response.status === 401) {
|
||||
logger.error("Unauthorized to delete profile");
|
||||
throw new Error("You are not authorized to delete this profile");
|
||||
} else if (response.status === 403) {
|
||||
logger.error("Forbidden to delete profile");
|
||||
throw new Error("You are not allowed to delete this profile");
|
||||
}
|
||||
}
|
||||
|
||||
logger.error("Error deleting profile:", errorStringForLog(error));
|
||||
handleApiError(error as AxiosError, "/api/partner/userProfile");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update profile location
|
||||
* @param profileData - Current profile data
|
||||
* @param latitude - New latitude
|
||||
* @param longitude - New longitude
|
||||
* @returns Updated profile data
|
||||
*/
|
||||
updateProfileLocation(
|
||||
profileData: ProfileData,
|
||||
latitude: number,
|
||||
longitude: number,
|
||||
): ProfileData {
|
||||
return {
|
||||
...profileData,
|
||||
latitude,
|
||||
longitude,
|
||||
includeLocation: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle location inclusion in profile
|
||||
* Toggle profile location visibility
|
||||
*
|
||||
* @param profileData - Current profile data
|
||||
* @returns Updated profile data
|
||||
*/
|
||||
@@ -215,6 +273,7 @@ export class ProfileService {
|
||||
|
||||
/**
|
||||
* Clear profile location
|
||||
*
|
||||
* @param profileData - Current profile data
|
||||
* @returns Updated profile data
|
||||
*/
|
||||
@@ -229,6 +288,7 @@ export class ProfileService {
|
||||
|
||||
/**
|
||||
* Reset profile to default state
|
||||
*
|
||||
* @returns Default profile data
|
||||
*/
|
||||
getDefaultProfile(): ProfileData {
|
||||
@@ -239,66 +299,27 @@ export class ProfileService {
|
||||
includeLocation: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for API errors with proper typing
|
||||
*/
|
||||
private isApiError(error: unknown): error is {
|
||||
response?: {
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
data?: { message?: string } | string;
|
||||
};
|
||||
} {
|
||||
return typeof error === "object" && error !== null && "response" in error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract error URL safely from error object
|
||||
*/
|
||||
private getErrorUrl(error: unknown): string | undefined {
|
||||
if (this.isAxiosError(error)) {
|
||||
return error.config?.url;
|
||||
}
|
||||
if (this.isApiError(error) && this.hasConfigProperty(error)) {
|
||||
const config = this.getConfigProperty(error);
|
||||
return config?.url;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if error has config property
|
||||
*/
|
||||
private hasConfigProperty(
|
||||
error: unknown,
|
||||
): error is { config?: { url?: string } } {
|
||||
return typeof error === "object" && error !== null && "config" in error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely extract config property from error
|
||||
*/
|
||||
private getConfigProperty(error: {
|
||||
config?: { url?: string };
|
||||
}): { url?: string } | undefined {
|
||||
return error.config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for AxiosError
|
||||
*/
|
||||
private isAxiosError(error: unknown): error is AxiosError {
|
||||
return error instanceof AxiosError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create a ProfileService instance
|
||||
*
|
||||
* @param axios - Axios instance for HTTP requests
|
||||
* @param partnerApiServer - Partner API server URL
|
||||
* @returns ProfileService instance
|
||||
*/
|
||||
export function createProfileService(
|
||||
axios: AxiosInstance,
|
||||
partnerApiServer: string,
|
||||
): ProfileService {
|
||||
// Register dependencies with service initialization manager
|
||||
const initManager = getServiceInitManager();
|
||||
initManager.registerService("AxiosInstance", []);
|
||||
initManager.registerService("PartnerApiServer", []);
|
||||
|
||||
// Mark dependencies as initialized
|
||||
initManager.markInitialized("AxiosInstance");
|
||||
initManager.markInitialized("PartnerApiServer");
|
||||
|
||||
return new ProfileService(axios, partnerApiServer);
|
||||
}
|
||||
|
||||
207
src/services/ServiceInitializationManager.ts
Normal file
207
src/services/ServiceInitializationManager.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Service Initialization Manager
|
||||
*
|
||||
* Manages the proper initialization order of services to prevent race conditions
|
||||
* and ensure dependencies are available when services are created.
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @since 2025-08-25
|
||||
*/
|
||||
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
/**
|
||||
* Service initialization status tracking
|
||||
*/
|
||||
interface ServiceStatus {
|
||||
name: string;
|
||||
initialized: boolean;
|
||||
dependencies: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service initialization manager to prevent race conditions
|
||||
*/
|
||||
export class ServiceInitializationManager {
|
||||
private static instance: ServiceInitializationManager;
|
||||
private serviceStatuses = new Map<string, ServiceStatus>();
|
||||
private initializationPromise: Promise<void> | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
/**
|
||||
* Get singleton instance
|
||||
*/
|
||||
static getInstance(): ServiceInitializationManager {
|
||||
if (!ServiceInitializationManager.instance) {
|
||||
ServiceInitializationManager.instance =
|
||||
new ServiceInitializationManager();
|
||||
}
|
||||
return ServiceInitializationManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a service that needs initialization
|
||||
*/
|
||||
registerService(name: string, dependencies: string[] = []): void {
|
||||
this.serviceStatuses.set(name, {
|
||||
name,
|
||||
initialized: false,
|
||||
dependencies,
|
||||
});
|
||||
|
||||
logger.debug("[ServiceInit] 🔧 Service registered:", {
|
||||
name,
|
||||
dependencies,
|
||||
totalServices: this.serviceStatuses.size,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a service as initialized
|
||||
*/
|
||||
markInitialized(name: string): void {
|
||||
const status = this.serviceStatuses.get(name);
|
||||
if (status) {
|
||||
status.initialized = true;
|
||||
logger.debug("[ServiceInit] ✅ Service initialized:", {
|
||||
name,
|
||||
totalInitialized: this.getInitializedCount(),
|
||||
totalServices: this.serviceStatuses.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a service as failed
|
||||
*/
|
||||
markFailed(name: string, error: string): void {
|
||||
const status = this.serviceStatuses.get(name);
|
||||
if (status) {
|
||||
status.error = error;
|
||||
logger.error("[ServiceInit] ❌ Service failed:", {
|
||||
name,
|
||||
error,
|
||||
totalFailed: this.getFailedCount(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of initialized services
|
||||
*/
|
||||
private getInitializedCount(): number {
|
||||
return Array.from(this.serviceStatuses.values()).filter(
|
||||
(s) => s.initialized,
|
||||
).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of failed services
|
||||
*/
|
||||
private getFailedCount(): number {
|
||||
return Array.from(this.serviceStatuses.values()).filter((s) => s.error)
|
||||
.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for all services to be initialized
|
||||
*/
|
||||
async waitForInitialization(): Promise<void> {
|
||||
if (this.initializationPromise) {
|
||||
return this.initializationPromise;
|
||||
}
|
||||
|
||||
this.initializationPromise = new Promise((resolve, reject) => {
|
||||
const checkInterval = setInterval(() => {
|
||||
const totalServices = this.serviceStatuses.size;
|
||||
const initializedCount = this.getInitializedCount();
|
||||
const failedCount = this.getFailedCount();
|
||||
|
||||
logger.debug("[ServiceInit] 🔍 Initialization progress:", {
|
||||
totalServices,
|
||||
initializedCount,
|
||||
failedCount,
|
||||
remaining: totalServices - initializedCount - failedCount,
|
||||
});
|
||||
|
||||
if (failedCount > 0) {
|
||||
clearInterval(checkInterval);
|
||||
const failedServices = Array.from(this.serviceStatuses.values())
|
||||
.filter((s) => s.error)
|
||||
.map((s) => `${s.name}: ${s.error}`);
|
||||
|
||||
const error = new Error(
|
||||
`Service initialization failed: ${failedServices.join(", ")}`,
|
||||
);
|
||||
logger.error("[ServiceInit] ❌ Initialization failed:", error);
|
||||
reject(error);
|
||||
} else if (initializedCount === totalServices) {
|
||||
clearInterval(checkInterval);
|
||||
logger.info(
|
||||
"[ServiceInit] 🎉 All services initialized successfully:",
|
||||
{
|
||||
totalServices,
|
||||
initializedCount,
|
||||
},
|
||||
);
|
||||
resolve();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Timeout after 30 seconds
|
||||
setTimeout(() => {
|
||||
clearInterval(checkInterval);
|
||||
const error = new Error(
|
||||
"Service initialization timeout after 30 seconds",
|
||||
);
|
||||
logger.error("[ServiceInit] ⏰ Initialization timeout:", error);
|
||||
reject(error);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
return this.initializationPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get initialization status summary
|
||||
*/
|
||||
getStatusSummary(): {
|
||||
total: number;
|
||||
initialized: number;
|
||||
failed: number;
|
||||
pending: number;
|
||||
services: ServiceStatus[];
|
||||
} {
|
||||
const services = Array.from(this.serviceStatuses.values());
|
||||
const total = services.length;
|
||||
const initialized = services.filter((s) => s.initialized).length;
|
||||
const failed = services.filter((s) => s.error).length;
|
||||
const pending = total - initialized - failed;
|
||||
|
||||
return {
|
||||
total,
|
||||
initialized,
|
||||
failed,
|
||||
pending,
|
||||
services,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the manager (useful for testing)
|
||||
*/
|
||||
reset(): void {
|
||||
this.serviceStatuses.clear();
|
||||
this.initializationPromise = null;
|
||||
logger.debug("[ServiceInit] 🔄 Manager reset");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to get the service initialization manager
|
||||
*/
|
||||
export const getServiceInitManager = (): ServiceInitializationManager => {
|
||||
return ServiceInitializationManager.getInstance();
|
||||
};
|
||||
298
src/utils/errorHandler.ts
Normal file
298
src/utils/errorHandler.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Standardized Error Handler
|
||||
*
|
||||
* Provides consistent error handling patterns across the TimeSafari codebase
|
||||
* to improve debugging, user experience, and maintainability.
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @since 2025-08-25
|
||||
*/
|
||||
|
||||
import { AxiosError } from "axios";
|
||||
import { logger } from "./logger";
|
||||
|
||||
/**
|
||||
* Standard error context for consistent logging
|
||||
*/
|
||||
export interface ErrorContext {
|
||||
component: string;
|
||||
operation: string;
|
||||
timestamp: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced error information for better debugging
|
||||
*/
|
||||
export interface EnhancedErrorInfo {
|
||||
errorType: "AxiosError" | "NetworkError" | "ValidationError" | "UnknownError";
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
errorData?: unknown;
|
||||
errorMessage: string;
|
||||
errorStack?: string;
|
||||
requestContext?: {
|
||||
url?: string;
|
||||
method?: string;
|
||||
headers?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Standardized error handler for API operations
|
||||
*
|
||||
* @param error - The error that occurred
|
||||
* @param context - Context information about the operation
|
||||
* @param operation - Description of the operation being performed
|
||||
* @returns Enhanced error information for consistent handling
|
||||
*/
|
||||
export function handleApiError(
|
||||
error: unknown,
|
||||
context: ErrorContext,
|
||||
operation: string,
|
||||
): EnhancedErrorInfo {
|
||||
const baseContext = {
|
||||
...context,
|
||||
operation,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (error instanceof AxiosError) {
|
||||
const axiosError = error as AxiosError;
|
||||
const status = axiosError.response?.status;
|
||||
const statusText = axiosError.response?.statusText;
|
||||
const errorData = axiosError.response?.data;
|
||||
|
||||
const enhancedError: EnhancedErrorInfo = {
|
||||
errorType: "AxiosError",
|
||||
status,
|
||||
statusText,
|
||||
errorData,
|
||||
errorMessage: axiosError.message,
|
||||
errorStack: axiosError.stack,
|
||||
requestContext: {
|
||||
url: axiosError.config?.url,
|
||||
method: axiosError.config?.method,
|
||||
headers: axiosError.config?.headers,
|
||||
},
|
||||
};
|
||||
|
||||
// Log with consistent format
|
||||
logger.error(
|
||||
`[${context.component}] ❌ ${operation} failed (AxiosError):`,
|
||||
{
|
||||
...baseContext,
|
||||
...enhancedError,
|
||||
},
|
||||
);
|
||||
|
||||
return enhancedError;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
const enhancedError: EnhancedErrorInfo = {
|
||||
errorType: "UnknownError",
|
||||
errorMessage: error.message,
|
||||
errorStack: error.stack,
|
||||
};
|
||||
|
||||
logger.error(`[${context.component}] ❌ ${operation} failed (Error):`, {
|
||||
...baseContext,
|
||||
...enhancedError,
|
||||
});
|
||||
|
||||
return enhancedError;
|
||||
}
|
||||
|
||||
// Handle unknown error types
|
||||
const enhancedError: EnhancedErrorInfo = {
|
||||
errorType: "UnknownError",
|
||||
errorMessage: String(error),
|
||||
};
|
||||
|
||||
logger.error(`[${context.component}] ❌ ${operation} failed (Unknown):`, {
|
||||
...baseContext,
|
||||
...enhancedError,
|
||||
});
|
||||
|
||||
return enhancedError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract human-readable error message from various error response formats
|
||||
*
|
||||
* @param errorData - Error response data
|
||||
* @returns Human-readable error message
|
||||
*/
|
||||
export function extractErrorMessage(errorData: unknown): string {
|
||||
if (typeof errorData === "string") {
|
||||
return errorData;
|
||||
}
|
||||
|
||||
if (typeof errorData === "object" && errorData !== null) {
|
||||
const obj = errorData as Record<string, unknown>;
|
||||
|
||||
// Try common error message fields
|
||||
if (obj.message && typeof obj.message === "string") {
|
||||
return obj.message;
|
||||
}
|
||||
|
||||
if (obj.error && typeof obj.error === "string") {
|
||||
return obj.error;
|
||||
}
|
||||
|
||||
if (obj.detail && typeof obj.detail === "string") {
|
||||
return obj.detail;
|
||||
}
|
||||
|
||||
if (obj.reason && typeof obj.reason === "string") {
|
||||
return obj.reason;
|
||||
}
|
||||
|
||||
// Fallback to stringified object
|
||||
return JSON.stringify(errorData);
|
||||
}
|
||||
|
||||
return String(errorData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create user-friendly error message from enhanced error info
|
||||
*
|
||||
* @param errorInfo - Enhanced error information
|
||||
* @param fallbackMessage - Fallback message if error details are insufficient
|
||||
* @returns User-friendly error message
|
||||
*/
|
||||
export function createUserMessage(
|
||||
errorInfo: EnhancedErrorInfo,
|
||||
fallbackMessage: string,
|
||||
): string {
|
||||
if (errorInfo.errorType === "AxiosError") {
|
||||
const status = errorInfo.status;
|
||||
const statusText = errorInfo.statusText;
|
||||
const errorMessage = extractErrorMessage(errorInfo.errorData);
|
||||
|
||||
if (status && statusText) {
|
||||
if (errorMessage && errorMessage !== "{}") {
|
||||
return `${fallbackMessage}: ${status} ${statusText} - ${errorMessage}`;
|
||||
}
|
||||
return `${fallbackMessage}: ${status} ${statusText}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
errorInfo.errorMessage &&
|
||||
errorInfo.errorMessage !== "Request failed with status code 0"
|
||||
) {
|
||||
return `${fallbackMessage}: ${errorInfo.errorMessage}`;
|
||||
}
|
||||
|
||||
return fallbackMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle specific HTTP status codes with appropriate user messages
|
||||
*
|
||||
* @param status - HTTP status code
|
||||
* @param errorData - Error response data
|
||||
* @param operation - Description of the operation
|
||||
* @returns User-friendly error message
|
||||
*/
|
||||
export function handleHttpStatus(
|
||||
status: number,
|
||||
errorData: unknown,
|
||||
operation: string,
|
||||
): string {
|
||||
const errorMessage = extractErrorMessage(errorData);
|
||||
|
||||
switch (status) {
|
||||
case 400:
|
||||
return errorMessage || `${operation} failed: Bad request`;
|
||||
case 401:
|
||||
return `${operation} failed: Authentication required`;
|
||||
case 403:
|
||||
return `${operation} failed: Access denied`;
|
||||
case 404:
|
||||
return errorMessage || `${operation} failed: Resource not found`;
|
||||
case 409:
|
||||
return errorMessage || `${operation} failed: Conflict with existing data`;
|
||||
case 422:
|
||||
return errorMessage || `${operation} failed: Validation error`;
|
||||
case 429:
|
||||
return `${operation} failed: Too many requests. Please try again later.`;
|
||||
case 500:
|
||||
return `${operation} failed: Server error. Please try again later.`;
|
||||
case 502:
|
||||
case 503:
|
||||
case 504:
|
||||
return `${operation} failed: Service temporarily unavailable. Please try again later.`;
|
||||
default:
|
||||
return errorMessage || `${operation} failed: HTTP ${status}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is a network-related error
|
||||
*
|
||||
* @param error - The error to check
|
||||
* @returns True if the error is network-related
|
||||
*/
|
||||
export function isNetworkError(error: unknown): boolean {
|
||||
if (error instanceof AxiosError) {
|
||||
return !error.response && !error.request;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
const message = error.message.toLowerCase();
|
||||
return (
|
||||
message.includes("network") ||
|
||||
message.includes("timeout") ||
|
||||
message.includes("connection") ||
|
||||
message.includes("fetch")
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is a timeout error
|
||||
*
|
||||
* @param error - The error to check
|
||||
* @returns True if the error is a timeout
|
||||
*/
|
||||
export function isTimeoutError(error: unknown): boolean {
|
||||
if (error instanceof AxiosError) {
|
||||
return (
|
||||
error.code === "ECONNABORTED" ||
|
||||
error.message.toLowerCase().includes("timeout")
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message.toLowerCase().includes("timeout");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create standardized error context for components
|
||||
*
|
||||
* @param component - Component name
|
||||
* @param operation - Operation being performed
|
||||
* @param additionalContext - Additional context information
|
||||
* @returns Standardized error context
|
||||
*/
|
||||
export function createErrorContext(
|
||||
component: string,
|
||||
operation: string,
|
||||
additionalContext: Record<string, unknown> = {},
|
||||
): ErrorContext {
|
||||
return {
|
||||
component,
|
||||
operation,
|
||||
timestamp: new Date().toISOString(),
|
||||
...additionalContext,
|
||||
};
|
||||
}
|
||||
482
src/utils/performanceOptimizer.ts
Normal file
482
src/utils/performanceOptimizer.ts
Normal file
@@ -0,0 +1,482 @@
|
||||
/**
|
||||
* Performance Optimizer
|
||||
*
|
||||
* Provides utilities for optimizing API calls, database queries, and component
|
||||
* rendering to improve TimeSafari application performance.
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @since 2025-08-25
|
||||
*/
|
||||
|
||||
import { logger } from "./logger";
|
||||
|
||||
/**
|
||||
* Batch operation configuration
|
||||
*/
|
||||
export interface BatchConfig {
|
||||
maxBatchSize: number;
|
||||
maxWaitTime: number;
|
||||
retryAttempts: number;
|
||||
retryDelay: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default batch configuration
|
||||
*/
|
||||
export const DEFAULT_BATCH_CONFIG: BatchConfig = {
|
||||
maxBatchSize: 10,
|
||||
maxWaitTime: 100, // milliseconds
|
||||
retryAttempts: 3,
|
||||
retryDelay: 1000, // milliseconds
|
||||
};
|
||||
|
||||
/**
|
||||
* Batched operation item
|
||||
*/
|
||||
export interface BatchItem<T, R> {
|
||||
id: string;
|
||||
data: T;
|
||||
resolve: (value: R) => void;
|
||||
reject: (error: Error) => void;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch processor for API operations
|
||||
*
|
||||
* Groups multiple similar operations into batches to reduce
|
||||
* the number of API calls and improve performance.
|
||||
*/
|
||||
export class BatchProcessor<T, R> {
|
||||
private items: BatchItem<T, R>[] = [];
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private processing = false;
|
||||
private config: BatchConfig;
|
||||
|
||||
constructor(
|
||||
private batchHandler: (items: T[]) => Promise<R[]>,
|
||||
private itemIdExtractor: (item: T) => string,
|
||||
config: Partial<BatchConfig> = {},
|
||||
) {
|
||||
this.config = { ...DEFAULT_BATCH_CONFIG, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an item to the batch
|
||||
*
|
||||
* @param data - Data to process
|
||||
* @returns Promise that resolves when the item is processed
|
||||
*/
|
||||
async add(data: T): Promise<R> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const item: BatchItem<T, R> = {
|
||||
id: this.itemIdExtractor(data),
|
||||
data,
|
||||
resolve,
|
||||
reject,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.items.push(item);
|
||||
|
||||
// Start timer if this is the first item
|
||||
if (this.items.length === 1) {
|
||||
this.startTimer();
|
||||
}
|
||||
|
||||
// Process immediately if batch is full
|
||||
if (this.items.length >= this.config.maxBatchSize) {
|
||||
this.processBatch();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the batch timer
|
||||
*/
|
||||
private startTimer(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
}
|
||||
|
||||
this.timer = setTimeout(() => {
|
||||
this.processBatch();
|
||||
}, this.config.maxWaitTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the current batch
|
||||
*/
|
||||
private async processBatch(): Promise<void> {
|
||||
if (this.processing || this.items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.processing = true;
|
||||
|
||||
// Clear timer
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
// Get current batch
|
||||
const currentItems = [...this.items];
|
||||
this.items = [];
|
||||
|
||||
try {
|
||||
logger.debug("[BatchProcessor] 🔄 Processing batch:", {
|
||||
batchSize: currentItems.length,
|
||||
itemIds: currentItems.map((item) => item.id),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Process batch
|
||||
const results = await this.batchHandler(
|
||||
currentItems.map((item) => item.data),
|
||||
);
|
||||
|
||||
// Map results back to items
|
||||
const resultMap = new Map<string, R>();
|
||||
results.forEach((result, index) => {
|
||||
const item = currentItems[index];
|
||||
if (item) {
|
||||
resultMap.set(item.id, result);
|
||||
}
|
||||
});
|
||||
|
||||
// Resolve promises
|
||||
currentItems.forEach((item) => {
|
||||
const result = resultMap.get(item.id);
|
||||
if (result !== undefined) {
|
||||
item.resolve(result);
|
||||
} else {
|
||||
item.reject(new Error(`No result found for item ${item.id}`));
|
||||
}
|
||||
});
|
||||
|
||||
logger.debug("[BatchProcessor] ✅ Batch processed successfully:", {
|
||||
batchSize: currentItems.length,
|
||||
resultsCount: results.length,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("[BatchProcessor] ❌ Batch processing failed:", {
|
||||
batchSize: currentItems.length,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Reject all items in the batch
|
||||
currentItems.forEach((item) => {
|
||||
item.reject(error instanceof Error ? error : new Error(String(error)));
|
||||
});
|
||||
} finally {
|
||||
this.processing = false;
|
||||
|
||||
// Start timer for remaining items if any
|
||||
if (this.items.length > 0) {
|
||||
this.startTimer();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current batch status
|
||||
*/
|
||||
getStatus(): {
|
||||
pendingItems: number;
|
||||
isProcessing: boolean;
|
||||
hasTimer: boolean;
|
||||
} {
|
||||
return {
|
||||
pendingItems: this.items.length,
|
||||
isProcessing: this.processing,
|
||||
hasTimer: this.timer !== null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all pending items
|
||||
*/
|
||||
clear(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
// Reject all pending items
|
||||
this.items.forEach((item) => {
|
||||
item.reject(new Error("Batch processor cleared"));
|
||||
});
|
||||
|
||||
this.items = [];
|
||||
this.processing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Database query optimizer
|
||||
*
|
||||
* Provides utilities for optimizing database queries and reducing
|
||||
* the number of database operations.
|
||||
*/
|
||||
export class DatabaseOptimizer {
|
||||
/**
|
||||
* Batch multiple SELECT queries into a single query
|
||||
*
|
||||
* @param baseQuery - Base SELECT query
|
||||
* @param ids - Array of IDs to query
|
||||
* @param idColumn - Name of the ID column
|
||||
* @returns Optimized query string
|
||||
*/
|
||||
static batchSelectQuery(
|
||||
baseQuery: string,
|
||||
ids: (string | number)[],
|
||||
idColumn: string,
|
||||
): string {
|
||||
if (ids.length === 0) {
|
||||
return baseQuery;
|
||||
}
|
||||
|
||||
if (ids.length === 1) {
|
||||
return `${baseQuery} WHERE ${idColumn} = ?`;
|
||||
}
|
||||
|
||||
const placeholders = ids.map(() => "?").join(", ");
|
||||
return `${baseQuery} WHERE ${idColumn} IN (${placeholders})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a query plan for multiple operations
|
||||
*
|
||||
* @param operations - Array of database operations
|
||||
* @returns Optimized query plan
|
||||
*/
|
||||
static createQueryPlan(
|
||||
operations: Array<{
|
||||
type: "SELECT" | "INSERT" | "UPDATE" | "DELETE";
|
||||
table: string;
|
||||
priority: number;
|
||||
}>,
|
||||
): Array<{
|
||||
type: "SELECT" | "INSERT" | "UPDATE" | "DELETE";
|
||||
table: string;
|
||||
priority: number;
|
||||
batchable: boolean;
|
||||
}> {
|
||||
return operations
|
||||
.map((op) => ({
|
||||
...op,
|
||||
batchable: op.type === "SELECT" || op.type === "INSERT",
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
// Sort by priority first, then by type
|
||||
if (a.priority !== b.priority) {
|
||||
return b.priority - a.priority;
|
||||
}
|
||||
|
||||
// SELECT operations first, then INSERT, UPDATE, DELETE
|
||||
const typeOrder = { SELECT: 0, INSERT: 1, UPDATE: 2, DELETE: 3 };
|
||||
return typeOrder[a.type] - typeOrder[b.type];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Component rendering optimizer
|
||||
*
|
||||
* Provides utilities for optimizing Vue component rendering
|
||||
* and reducing unnecessary re-renders.
|
||||
*/
|
||||
export class ComponentOptimizer {
|
||||
/**
|
||||
* Debounce function calls to prevent excessive execution
|
||||
*
|
||||
* @param func - Function to debounce
|
||||
* @param wait - Wait time in milliseconds
|
||||
* @returns Debounced function
|
||||
*/
|
||||
static debounce<T extends (...args: unknown[]) => unknown>(
|
||||
func: T,
|
||||
wait: number,
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
func(...args);
|
||||
}, wait);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Throttle function calls to limit execution frequency
|
||||
*
|
||||
* @param func - Function to throttle
|
||||
* @param limit - Time limit in milliseconds
|
||||
* @returns Throttled function
|
||||
*/
|
||||
static throttle<T extends (...args: unknown[]) => unknown>(
|
||||
func: T,
|
||||
limit: number,
|
||||
): (...args: Parameters<T>) => void {
|
||||
let inThrottle = false;
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
if (!inThrottle) {
|
||||
func(...args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => {
|
||||
inThrottle = false;
|
||||
}, limit);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoize function results to avoid redundant computation
|
||||
*
|
||||
* @param func - Function to memoize
|
||||
* @param keyGenerator - Function to generate cache keys
|
||||
* @returns Memoized function
|
||||
*/
|
||||
static memoize<T extends (...args: unknown[]) => unknown, K>(
|
||||
func: T,
|
||||
keyGenerator: (...args: Parameters<T>) => K,
|
||||
): T {
|
||||
const cache = new Map<K, unknown>();
|
||||
|
||||
return ((...args: Parameters<T>) => {
|
||||
const key = keyGenerator(...args);
|
||||
|
||||
if (cache.has(key)) {
|
||||
return cache.get(key);
|
||||
}
|
||||
|
||||
const result = func(...args);
|
||||
cache.set(key, result);
|
||||
return result;
|
||||
}) as T;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performance monitoring utility
|
||||
*
|
||||
* Tracks and reports performance metrics for optimization analysis.
|
||||
*/
|
||||
export class PerformanceMonitor {
|
||||
private static instance: PerformanceMonitor;
|
||||
private metrics = new Map<
|
||||
string,
|
||||
Array<{ timestamp: number; duration: number }>
|
||||
>();
|
||||
|
||||
private constructor() {}
|
||||
|
||||
/**
|
||||
* Get singleton instance
|
||||
*/
|
||||
static getInstance(): PerformanceMonitor {
|
||||
if (!PerformanceMonitor.instance) {
|
||||
PerformanceMonitor.instance = new PerformanceMonitor();
|
||||
}
|
||||
return PerformanceMonitor.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start timing an operation
|
||||
*
|
||||
* @param operationName - Name of the operation
|
||||
* @returns Function to call when operation completes
|
||||
*/
|
||||
startTiming(operationName: string): () => void {
|
||||
const startTime = performance.now();
|
||||
|
||||
return () => {
|
||||
const duration = performance.now() - startTime;
|
||||
this.recordMetric(operationName, duration);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a performance metric
|
||||
*
|
||||
* @param operationName - Name of the operation
|
||||
* @param duration - Duration in milliseconds
|
||||
*/
|
||||
private recordMetric(operationName: string, duration: number): void {
|
||||
if (!this.metrics.has(operationName)) {
|
||||
this.metrics.set(operationName, []);
|
||||
}
|
||||
|
||||
const operationMetrics = this.metrics.get(operationName)!;
|
||||
operationMetrics.push({
|
||||
timestamp: Date.now(),
|
||||
duration,
|
||||
});
|
||||
|
||||
// Keep only last 100 metrics per operation
|
||||
if (operationMetrics.length > 100) {
|
||||
operationMetrics.splice(0, operationMetrics.length - 100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get performance summary for an operation
|
||||
*
|
||||
* @param operationName - Name of the operation
|
||||
* @returns Performance statistics
|
||||
*/
|
||||
getPerformanceSummary(operationName: string): {
|
||||
count: number;
|
||||
average: number;
|
||||
min: number;
|
||||
max: number;
|
||||
recentAverage: number;
|
||||
} | null {
|
||||
const metrics = this.metrics.get(operationName);
|
||||
if (!metrics || metrics.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const durations = metrics.map((m) => m.duration);
|
||||
const recentMetrics = metrics.slice(-10); // Last 10 metrics
|
||||
|
||||
return {
|
||||
count: metrics.length,
|
||||
average: durations.reduce((a, b) => a + b, 0) / durations.length,
|
||||
min: Math.min(...durations),
|
||||
max: Math.max(...durations),
|
||||
recentAverage:
|
||||
recentMetrics.reduce((a, b) => a + b.duration, 0) /
|
||||
recentMetrics.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all performance metrics
|
||||
*/
|
||||
getAllMetrics(): Map<string, Array<{ timestamp: number; duration: number }>> {
|
||||
return new Map(this.metrics);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all performance metrics
|
||||
*/
|
||||
clearMetrics(): void {
|
||||
this.metrics.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to get the performance monitor
|
||||
*/
|
||||
export const getPerformanceMonitor = (): PerformanceMonitor => {
|
||||
return PerformanceMonitor.getInstance();
|
||||
};
|
||||
Reference in New Issue
Block a user