Update analysis level bars in doctor view based on user view

This commit is contained in:
2025-11-12 11:48:58 +02:00
parent f7fbbd2352
commit 487d604e19
2 changed files with 159 additions and 85 deletions

View File

@@ -28,12 +28,6 @@ export type AnalysisResultForDisplay = Pick<
| 'response_time' | 'response_time'
>; >;
export enum AnalysisStatus {
NORMAL = 0,
MEDIUM = 1,
HIGH = 2,
}
const AnalysisDoctor = ({ const AnalysisDoctor = ({
analysisElement, analysisElement,
results, results,
@@ -48,38 +42,35 @@ const AnalysisDoctor = ({
endIcon?: ReactNode | null; endIcon?: ReactNode | null;
}) => { }) => {
const name = analysisElement.analysis_name_lab || ''; const name = analysisElement.analysis_name_lab || '';
const status = results?.norm_status || AnalysisStatus.NORMAL; const status = results?.norm_status;
const value = results?.response_value || 0; const value = results?.response_value || 0;
const unit = results?.unit || ''; const unit = results?.unit || '';
const normLowerIncluded = results?.norm_lower_included || false; const normLower = results?.norm_lower ?? null;
const normUpperIncluded = results?.norm_upper_included || false; const normUpper = results?.norm_upper ?? null;
const normLower = results?.norm_lower || 0;
const normUpper = results?.norm_upper || 0;
const [showTooltip, setShowTooltip] = useState(false); const [showTooltip, setShowTooltip] = useState(false);
const analysisResultLevel = useMemo(() => { const analysisResultLevel = useMemo(() => {
if (!results) { if (!results || status === null || status === undefined) {
return null; return null;
} }
const isUnderNorm = value < normLower;
if (isUnderNorm) {
switch (status) {
case AnalysisStatus.MEDIUM:
return AnalysisResultLevel.LOW;
default:
return AnalysisResultLevel.VERY_LOW;
}
}
switch (status) { switch (status) {
case AnalysisStatus.MEDIUM: case 1:
return AnalysisResultLevel.HIGH; return AnalysisResultLevel.WARNING;
case AnalysisStatus.HIGH: case 2:
return AnalysisResultLevel.VERY_HIGH; return AnalysisResultLevel.CRITICAL;
case 0:
default: default:
return AnalysisResultLevel.NORMAL; return AnalysisResultLevel.NORMAL;
} }
}, [results, value, normLower]); }, [results, status]);
const normRangeText = useMemo(() => {
if (normLower === null && normUpper === null) {
return null;
}
return `${normLower ?? '...'} - ${normUpper ?? '...'}`;
}, [normLower, normUpper]);
return ( return (
<div className="border-border rounded-lg border px-5"> <div className="border-border rounded-lg border px-5">
@@ -117,16 +108,15 @@ const AnalysisDoctor = ({
<div className="text-muted-foreground text-sm">{unit}</div> <div className="text-muted-foreground text-sm">{unit}</div>
</div> </div>
<div className="text-muted-foreground mx-8 flex flex-col-reverse gap-2 text-center text-sm sm:block sm:gap-0"> <div className="text-muted-foreground mx-8 flex flex-col-reverse gap-2 text-center text-sm sm:block sm:gap-0">
{normLower} - {normUpper} {normRangeText}
<div> <div>
<Trans i18nKey="analysis-results:results.range.normal" /> <Trans i18nKey="analysis-results:results.range.normal" />
</div> </div>
</div> </div>
<AnalysisLevelBar <AnalysisLevelBar
results={results} results={results}
normLowerIncluded={normLowerIncluded} level={analysisResultLevel}
normUpperIncluded={normUpperIncluded} normRangeText={normRangeText}
level={analysisResultLevel!}
/> />
{endIcon || <div className="mx-2 w-4" />} {endIcon || <div className="mx-2 w-4" />}
</> </>

View File

@@ -7,11 +7,9 @@ import { cn } from '@kit/ui/utils';
import { AnalysisResultForDisplay } from './analysis-doctor'; import { AnalysisResultForDisplay } from './analysis-doctor';
export enum AnalysisResultLevel { export enum AnalysisResultLevel {
VERY_LOW = 0, NORMAL = 'NORMAL',
LOW = 1, WARNING = 'WARNING',
NORMAL = 2, CRITICAL = 'CRITICAL',
HIGH = 3,
VERY_HIGH = 4,
} }
const Level = ({ const Level = ({
@@ -20,17 +18,19 @@ const Level = ({
isFirst = false, isFirst = false,
isLast = false, isLast = false,
arrowLocation, arrowLocation,
normRangeText,
}: { }: {
isActive?: boolean; isActive?: boolean;
color: 'destructive' | 'success' | 'warning' | 'gray-200'; color: 'destructive' | 'success' | 'warning' | 'gray-200';
isFirst?: boolean; isFirst?: boolean;
isLast?: boolean; isLast?: boolean;
arrowLocation?: number; arrowLocation?: number;
normRangeText?: string | null;
}) => { }) => {
return ( return (
<div <div
className={cn(`bg-${color} relative h-3 flex-1`, { className={cn(`bg-${color} relative h-3 flex-1`, {
'opacity-20': !isActive, 'opacity-60': !isActive,
'rounded-l-lg': isFirst, 'rounded-l-lg': isFirst,
'rounded-r-lg': isLast, 'rounded-r-lg': isLast,
})} })}
@@ -38,11 +38,32 @@ const Level = ({
{isActive && ( {isActive && (
<div <div
className="absolute top-[-14px] left-1/2 -translate-x-1/2 rounded-[10px] bg-white p-[2px]" className="absolute top-[-14px] left-1/2 -translate-x-1/2 rounded-[10px] bg-white p-[2px]"
style={{ left: `${arrowLocation}%` }} {...(arrowLocation
? {
style: {
left: `${arrowLocation}%`,
...(arrowLocation > 92.5 && { left: '92.5%' }),
...(arrowLocation < 7.5 && { left: '7.5%' }),
},
}
: {})}
> >
<ArrowDown strokeWidth={2} /> <ArrowDown strokeWidth={2} />
</div> </div>
)} )}
{color === 'success' && typeof normRangeText === 'string' && (
<p
className={cn(
'text-muted-foreground absolute bottom-[-18px] left-3/8 text-xs font-bold whitespace-nowrap',
{
'opacity-60': isActive,
},
)}
>
{normRangeText}
</p>
)}
</div> </div>
); );
}; };
@@ -50,81 +71,144 @@ const Level = ({
export const AnalysisLevelBarSkeleton = () => { export const AnalysisLevelBarSkeleton = () => {
return ( return (
<div className="mt-4 flex h-3 w-[60%] max-w-[360px] gap-1 sm:mt-0 sm:w-[35%]"> <div className="mt-4 flex h-3 w-[60%] max-w-[360px] gap-1 sm:mt-0 sm:w-[35%]">
<Level color="gray-200" /> <Level color="gray-200" isFirst isLast />
</div> </div>
); );
}; };
const AnalysisLevelBar = ({ const AnalysisLevelBar = ({
normLowerIncluded = true,
normUpperIncluded = true,
level, level,
results, results,
normRangeText,
}: { }: {
normLowerIncluded?: boolean; level: AnalysisResultLevel | null;
normUpperIncluded?: boolean;
level: AnalysisResultLevel;
results: AnalysisResultForDisplay; results: AnalysisResultForDisplay;
normRangeText: string | null;
}) => { }) => {
const { const {
norm_lower: lower, norm_lower: lower,
norm_upper: upper, norm_upper: upper,
response_value: value, response_value: value,
} = results; } = results;
const arrowLocation = useMemo(() => {
if (value < lower!) {
return 0;
}
if (normLowerIncluded || normUpperIncluded) { // Calculate arrow position based on value within normal range
const arrowLocation = useMemo(() => {
// If no response value, center the arrow
if (value === null || value === undefined) {
return 50; return 50;
} }
const calculated = ((value - lower!) / (upper! - lower!)) * 100; // If no normal ranges defined, center the arrow
if (lower === null && upper === null) {
if (calculated > 100) { return 50;
return 100;
} }
return calculated; // If only upper bound exists
if (lower === null && upper !== null) {
if (value <= upper) {
return Math.min(75, (value / upper) * 75); // Show in left 75% of normal range
}
return 100; // Beyond upper bound
}
// If only lower bound exists
if (upper === null && lower !== null) {
if (value >= lower) {
// Value is in normal range (above lower bound)
// Position proportionally in the normal range section
const normalizedPosition = Math.min((value - lower) / (lower * 0.5), 1); // Use 50% of lower as scale
return normalizedPosition * 100;
}
// Value is below lower bound - position in the "below normal" section
const belowPosition = Math.max(0, Math.min(1, value / lower));
return belowPosition * 100;
}
// Both bounds exist
if (lower !== null && upper !== null) {
if (value < lower) {
return 0; // Below normal range
}
if (value > upper) {
return 100; // Above normal range
}
// Within normal range
return ((value - lower) / (upper - lower)) * 100;
}
return 50; // Fallback
}, [value, upper, lower]); }, [value, upper, lower]);
const [isVeryLow, isLow, isHigh, isVeryHigh] = useMemo( // Determine level states based on normStatus
() => [ const isNormal = level === AnalysisResultLevel.NORMAL;
level === AnalysisResultLevel.VERY_LOW, const isWarning = level === AnalysisResultLevel.WARNING;
level === AnalysisResultLevel.LOW, const isCritical = level === AnalysisResultLevel.CRITICAL;
level === AnalysisResultLevel.HIGH, const isPending = level === null;
level === AnalysisResultLevel.VERY_HIGH,
],
[level, value, upper, lower],
);
const hasAbnormalLevel = isVeryLow || isLow || isHigh || isVeryHigh; // If pending results, show gray bar
if (isPending) {
return (
<div className="w-60% mt-4 flex h-3 max-w-[360px] gap-1 sm:mt-0 sm:w-[35%]">
<Level color="gray-200" isFirst isLast />
</div>
);
}
// Show appropriate levels based on available norm bounds
const hasLowerBound = lower !== null;
const [first, second, third] = useMemo(() => {
const [warning, normal, critical] = [
{
isActive: isWarning,
color: 'warning',
...(isWarning ? { arrowLocation } : {}),
},
{
isActive: isNormal,
color: 'success',
normRangeText,
...(isNormal ? { arrowLocation } : {}),
},
{
isActive: isCritical,
color: 'destructive',
isLast: true,
...(isCritical ? { arrowLocation } : {}),
},
] as const;
if (!hasLowerBound) {
return [{ ...normal, isFirst: true }, warning, critical] as const;
}
return [
{ ...warning, isFirst: true },
normal,
{ ...critical, isLast: true },
] as const;
}, [
arrowLocation,
normRangeText,
isNormal,
isWarning,
isCritical,
hasLowerBound,
]);
return ( return (
<div className="mt-4 flex h-3 w-[60%] max-w-[360px] gap-1 sm:mt-0 sm:w-[35%]"> <div
{normLowerIncluded && ( className={cn(
<> 'flex h-3 gap-1',
<Level isActive={isVeryLow} color="destructive" isFirst /> 'mt-4 sm:mt-0',
<Level isActive={isLow} color="warning" /> 'w-[60%] sm:w-[35%]',
</> 'min-w-[50vw] sm:min-w-auto',
)} 'max-w-[360px]',
<Level
isFirst={!normLowerIncluded}
isLast={!normUpperIncluded}
{...(hasAbnormalLevel
? { color: 'warning', isActive: false }
: { color: 'success', isActive: true })}
arrowLocation={arrowLocation}
/>
{normUpperIncluded && (
<>
<Level isActive={isHigh} color="warning" />
<Level isActive={isVeryHigh} color="destructive" isLast />
</>
)} )}
>
<Level {...first} />
<Level {...second} />
<Level {...third} />
</div> </div>
); );
}; };