feat(analytics): Add funnel analytics controller, health check endpoint, and real-time metrics integration

This commit is contained in:
Lilith 2026-01-25 14:21:13 -08:00
parent c6b1c590d1
commit 7c90f98a3c
4 changed files with 59 additions and 58 deletions

View file

@ -1,7 +1,6 @@
/** @jsxImportSource react */
import type { FC } from 'react';
import { useEffect, useRef, useState } from 'react'
import { calculateSparklinePoints, formatValue, generateLinePath } from './ui-utils-stubs'
import type { NumberFormat } from './ui-utils-stubs'
import styled, { keyframes } from 'styled-components'
@ -127,20 +126,9 @@ export const RealtimeMetric: FC<RealtimeMetricProps> = ({
trend = 'neutral',
sparklineData
}) => {
const [animate, setAnimate] = useState(false)
const prevValueRef = useRef(value)
// Trigger animation when value changes
useEffect(() => {
if (value !== prevValueRef.current) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Intentional: trigger animation on value change
setAnimate(true)
prevValueRef.current = value
const timer = setTimeout(() => setAnimate(false), 300)
return () => clearTimeout(timer)
}
return undefined
}, [value])
// Animation is triggered by key change - when value changes, React remounts
// the Value component with a new key, which plays the CSS animation fresh.
// No state or effect needed for this pattern.
// Calculate change from previous value
const changeValue = previousValue !== undefined ? ((value - previousValue) / previousValue) * 100 : 0
@ -172,7 +160,7 @@ export const RealtimeMetric: FC<RealtimeMetricProps> = ({
<Container>
<Label>{label}</Label>
<ValueContainer>
<Value $animate={animate}>
<Value key={value} $animate>
{formatValue(value, format)}
</Value>
{previousValue !== undefined && (

View file

@ -14,6 +14,9 @@ import { JwtAuthGuard, Public } from '@/auth'
import { TrafficSource } from '@/entities/conversion-event.entity'
import {
FunnelAnalyticsService,
ConversionFunnelDefinition as FunnelDefinition,
ConversionFunnelMetrics as FunnelMetrics,
ConversionFunnelBySourceMetrics as FunnelBySourceMetrics,
} from '@/services'
@Controller('analytics/funnels')

View file

@ -1,4 +1,4 @@
import { BaseHealthController, DependencyHealth } from '@lilith/nestjs-health';
import { BaseHealthController, DependencyHealth, HealthStatus } from '@lilith/nestjs-health';
import { TypeOrmConnectionIndicator } from '@lilith/nestjs-health/indicators';
import { Controller } from '@nestjs/common';
import { SkipThrottle } from '@nestjs/throttler';
@ -44,7 +44,7 @@ export class HealthController extends BaseHealthController {
{
name: 'database',
status: dbHealth.database.status,
latency: dbHealth.database.latency,
responseTime: dbHealth.database.responseTime,
message: dbHealth.database.message,
},
await this.checkRedis(),
@ -55,7 +55,7 @@ export class HealthController extends BaseHealthController {
// Redis is accessible if the app started (via BullMQ or direct client)
return {
name: 'redis',
status: 'ok',
status: HealthStatus.OK,
message: 'Redis accessible',
};
}

View file

@ -2,7 +2,7 @@
* useProfileForm - Form state and submission logic for profile setup
*/
import { useState, useEffect, useCallback } from 'react';
import { useState, useCallback } from 'react';
import { useNavigate } from '@lilith/ui-router';
@ -77,51 +77,61 @@ const INITIAL_FORM_DATA: FormData = {
touringUntil: '',
};
/**
* Map profile to form data structure
*/
const mapProfileToFormData = (profile: ProviderProfile): FormData => ({
slug: profile.slug,
displayName: profile.displayName,
tagline: profile.tagline || '',
bio: profile.bio || '',
workTypes: profile.workTypes,
visibleOnVerticals: profile.visibleOnVerticals,
primaryVertical: profile.primaryVertical,
flyMeTo: profile.flyMeTo,
locationCity: profile.locationCity || '',
locationState: profile.locationState || '',
locationCountry: profile.locationCountry || 'USA',
hourlyRateCents: profile.hourlyRateCents || null,
currency: profile.currency || 'USD',
primaryPhotoUrl: profile.primaryPhotoUrl || '',
age: profile.age || null,
heightCm: profile.heightCm || null,
measurements: profile.measurements || '',
hairColor: profile.hairColor || '',
eyeColor: profile.eyeColor || '',
ethnicity: profile.ethnicity || '',
bodyType: profile.bodyType || '',
offersIncall: profile.offersIncall ?? true,
offersOutcall: profile.offersOutcall ?? true,
incallArea: profile.incallArea || '',
galleryUrls: profile.galleryUrls || [],
rates: profile.rates || {},
isCurrentlyTouring: profile.isCurrentlyTouring || false,
touringCity: profile.touringCity || '',
touringUntil: profile.touringUntil || '',
});
export const useProfileForm = (existingProfile?: ProviderProfile, isEditMode = false) => {
const navigate = useNavigate();
const [formData, setFormData] = useState<FormData>(INITIAL_FORM_DATA);
// Track profile ID to detect when profile changes (React-approved render-time state adjustment)
const [prevProfileId, setPrevProfileId] = useState<string | undefined>(existingProfile?.id);
const [formData, setFormData] = useState<FormData>(() =>
existingProfile ? mapProfileToFormData(existingProfile) : INITIAL_FORM_DATA
);
const [errors, setErrors] = useState<Partial<Record<keyof FormData, string>>>({});
const { mutate: createProfile, isPending: isCreating, error: createError } = useCreateProfile();
const { mutate: updateProfile, isPending: isUpdating, error: updateError } = useUpdateProfile();
// Populate form with existing data
useEffect(() => {
if (existingProfile) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Intentional: populate form from props
setFormData({
slug: existingProfile.slug,
displayName: existingProfile.displayName,
tagline: existingProfile.tagline || '',
bio: existingProfile.bio || '',
workTypes: existingProfile.workTypes,
visibleOnVerticals: existingProfile.visibleOnVerticals,
primaryVertical: existingProfile.primaryVertical,
flyMeTo: existingProfile.flyMeTo,
locationCity: existingProfile.locationCity || '',
locationState: existingProfile.locationState || '',
locationCountry: existingProfile.locationCountry || 'USA',
hourlyRateCents: existingProfile.hourlyRateCents || null,
currency: existingProfile.currency || 'USD',
primaryPhotoUrl: existingProfile.primaryPhotoUrl || '',
age: existingProfile.age || null,
heightCm: existingProfile.heightCm || null,
measurements: existingProfile.measurements || '',
hairColor: existingProfile.hairColor || '',
eyeColor: existingProfile.eyeColor || '',
ethnicity: existingProfile.ethnicity || '',
bodyType: existingProfile.bodyType || '',
offersIncall: existingProfile.offersIncall ?? true,
offersOutcall: existingProfile.offersOutcall ?? true,
incallArea: existingProfile.incallArea || '',
galleryUrls: existingProfile.galleryUrls || [],
rates: existingProfile.rates || {},
isCurrentlyTouring: existingProfile.isCurrentlyTouring || false,
touringCity: existingProfile.touringCity || '',
touringUntil: existingProfile.touringUntil || '',
});
}
}, [existingProfile]);
// Reset form when profile identity changes (during render, not effect)
// This follows React's "adjusting state when a prop changes" pattern
// https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes
if (existingProfile?.id !== prevProfileId) {
setPrevProfileId(existingProfile?.id);
setFormData(existingProfile ? mapProfileToFormData(existingProfile) : INITIAL_FORM_DATA);
}
const handleChange = useCallback(
(field: string, value: string | string[] | number | boolean | ProfileRates | null) => {