CG-Feedback-Monitor/src/utils/ratingTrend.ts

74 lines
3.1 KiB
TypeScript

import type { FeedbackCriterionItem, FeedbackRating } from '../db'
import { RATING_NUM_MAP, RATING_TREND_DELTA_THRESHOLD } from '../config/constants'
export interface MeetingScorePoint {
meetingId: number
date: string
score: number
}
export interface TrendResult {
suggestion: number | null
history: MeetingScorePoint[]
trend: 'up' | 'down' | 'stable' | null
delta: number | null
}
/**
* Ein Wert pro Meeting für eine Gruppe von Kriterien (eine Kategorie oder alle Items für "overall").
* Meetings ohne bewertetes Item aus der Gruppe werden übersprungen — ein Delivery-Call, der eine
* Kategorie nicht abfragt, darf deren Zeitreihe nicht verfälschen.
*/
export function buildGroupMeetingScores(
items: FeedbackCriterionItem[],
meetingsChronological: { id: number; date: string }[],
getScore: (meetingId: number, itemId: number) => FeedbackRating | null,
): MeetingScorePoint[] {
const points: MeetingScorePoint[] = []
for (const meeting of meetingsChronological) {
const rated = items
.map(i => ({ score: getScore(meeting.id, i.id!), w: i.weight ?? 1 }))
.filter((x): x is { score: FeedbackRating; w: number } => x.score !== null && x.score !== 'na')
if (rated.length === 0) continue
const wSum = rated.reduce((s, x) => s + x.w, 0)
if (wSum === 0) continue // alle beteiligten Kriterien auf Gewicht 0 → keine Aussage möglich, sonst NaN < x ist überall false und fällt auf "Fully" durch
const score = rated.reduce((s, x) => s + RATING_NUM_MAP[x.score] * x.w, 0) / wSum
points.push({ meetingId: meeting.id, date: meeting.date, score })
}
return points
}
/**
* Lineare Zeitgewichtung: rank r (1-basiert, 1 = ältester Datenpunkt) → weight = 1 + (r-1) * weightStep.
* Trend wird aus dem Delta zwischen erstem und letztem Rohwert abgeleitet (nicht dem gewichteten Wert),
* da das ohnehin nur ab 2 Datenpunkten sinnvoll ist und der gewichtete Wert selbst schon Teil des
* Vorschlags ist.
*/
export function computeTrend(history: MeetingScorePoint[], weightStep: number): TrendResult {
if (history.length === 0) return { suggestion: null, history, trend: null, delta: null }
const weights = previewWeights(history.length, weightStep)
const wSum = weights.reduce((s, w) => s + w, 0)
const suggestion = history.reduce((s, p, i) => s + p.score * weights[i], 0) / wSum
if (history.length < 2) return { suggestion, history, trend: null, delta: null }
const delta = history[history.length - 1].score - history[0].score
const trend = delta >= RATING_TREND_DELTA_THRESHOLD ? 'up'
: delta <= -RATING_TREND_DELTA_THRESHOLD ? 'down'
: 'stable'
return { suggestion, history, trend, delta }
}
export function numberToRating(avg: number): FeedbackRating {
if (avg < 1.5) return 'not_client_ready'
if (avg < 2.5) return 'partially_client_ready'
if (avg < 3.5) return 'nearly_client_ready'
return 'client_ready'
}
/** Resultierende Gewichte für k chronologische Datenpunkte, z.B. previewWeights(3, 0.5) => [1, 1.5, 2]. */
export function previewWeights(k: number, weightStep: number): number[] {
return Array.from({ length: k }, (_, i) => 1 + i * weightStep)
}