Skip to content

Commit 0ec0278

Browse files
authored
Merge pull request #21 from prazgaitis/fix-strava-webhooks-2
Fix activity logging scoring consistency and preserve activity type configs
2 parents c3e245f + 082141c commit 0ec0278

3 files changed

Lines changed: 113 additions & 13 deletions

File tree

apps/web/components/admin/admin-activity-types-table.tsx

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export function AdminActivityTypesTable({
6060
const [editContributes, setEditContributes] = useState(true);
6161
const [editNegative, setEditNegative] = useState(false);
6262
const [editThresholds, setEditThresholds] = useState<BonusThreshold[]>([]);
63+
const [editScoringConfig, setEditScoringConfig] = useState<Record<string, unknown> | null>(null);
6364

6465
const [statusMessage, setStatusMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
6566
const [isPending, setIsPending] = useState(false);
@@ -92,35 +93,74 @@ export function AdminActivityTypesTable({
9293
};
9394

9495
const startEditing = (item: ActivityType) => {
96+
const scoringConfig = (item.scoringConfig as Record<string, unknown>) ?? {};
9597
const basePoints = getBasePoints(item);
9698
setEditingId(item._id);
9799
setEditName(item.name);
98100
setEditPoints(String(basePoints));
99101
setEditContributes(item.contributesToStreak);
100102
setEditNegative(item.isNegative);
101103
setEditThresholds((item.bonusThresholds as BonusThreshold[]) || []);
104+
setEditScoringConfig(scoringConfig);
102105
};
103106

104107
const cancelEditing = () => {
105108
setEditingId(null);
106109
setEditThresholds([]);
110+
setEditScoringConfig(null);
111+
};
112+
113+
const withUpdatedPoints = (
114+
currentConfig: Record<string, unknown>,
115+
points: number
116+
): Record<string, unknown> => {
117+
const nextConfig = { ...currentConfig };
118+
const scoringType = typeof nextConfig.type === "string" ? nextConfig.type : undefined;
119+
120+
if (scoringType === "completion") {
121+
if ("fixedPoints" in nextConfig) {
122+
nextConfig.fixedPoints = points;
123+
} else {
124+
nextConfig.points = points;
125+
}
126+
return nextConfig;
127+
}
128+
129+
if (
130+
scoringType === "unit_based" ||
131+
scoringType === "distance" ||
132+
scoringType === "duration" ||
133+
scoringType === "count" ||
134+
typeof nextConfig.unit === "string"
135+
) {
136+
nextConfig.pointsPerUnit = points;
137+
return nextConfig;
138+
}
139+
140+
nextConfig.basePoints = points;
141+
return nextConfig;
107142
};
108143

109144
const handleUpdate = async (e: React.FormEvent) => {
110145
e.preventDefault();
111146
if (!editingId) return;
112147
setIsPending(true);
113148
try {
149+
const parsedPoints = Number(editPoints);
150+
const safePoints = Number.isFinite(parsedPoints) ? parsedPoints : 1;
151+
const nextScoringConfig = withUpdatedPoints(editScoringConfig ?? {}, safePoints);
152+
114153
await updateActivityType({
115154
activityTypeId: editingId as Id<"activityTypes">,
116155
name: editName,
117-
scoringConfig: { basePoints: Number(editPoints) || 1 },
156+
scoringConfig: nextScoringConfig,
118157
contributesToStreak: editContributes,
119158
isNegative: editNegative,
120159
bonusThresholds: editThresholds,
121160
});
122161
setEditingId(null);
123162
setEditThresholds([]);
163+
setEditScoringConfig(null);
124164
setStatusMessage({ type: "success", text: "Saved" });
125165
clearStatus();
126166
} catch {
@@ -149,7 +189,7 @@ export function AdminActivityTypesTable({
149189

150190
const getBasePoints = (item: ActivityType) => {
151191
const config = item.scoringConfig as Record<string, unknown>;
152-
return Number(config?.basePoints ?? config?.points ?? config?.pointsPerUnit ?? 1) || 1;
192+
return Number(config?.pointsPerUnit ?? config?.fixedPoints ?? config?.points ?? config?.basePoints ?? 1) || 1;
153193
};
154194

155195
return (

apps/web/components/dashboard/activity-log-dialog.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@ function parseNumber(value: unknown): number | null {
8888
return Number.isFinite(numberValue) ? numberValue : null;
8989
}
9090

91+
function formatPoints(value: number): string {
92+
const normalized = Math.round((value + Number.EPSILON) * 100) / 100;
93+
return normalized.toString();
94+
}
95+
9196
interface MediaPreview {
9297
file: File;
9398
url: string;
@@ -677,12 +682,12 @@ export function ActivityLogDialog({ challengeId, challengeStartDate, trigger }:
677682
{successState.activityName}
678683
</p>
679684
<p className="mt-1 text-2xl font-bold text-green-500">
680-
+{successState.pointsEarned.toFixed(0)} pts
685+
+{formatPoints(successState.pointsEarned)} pts
681686
</p>
682687
{successState.triggeredBonuses.length > 0 && (
683688
<div className="mt-3 space-y-1">
684689
<p className="text-xs text-muted-foreground">
685-
{successState.basePoints.toFixed(0)} base + {successState.bonusPoints.toFixed(0)} bonus
690+
{formatPoints(successState.basePoints)} base + {formatPoints(successState.bonusPoints)} bonus
686691
</p>
687692
{successState.triggeredBonuses.map((bonus, i) => (
688693
<Badge key={i} variant="secondary" className="bg-amber-500/10 text-amber-500">

packages/backend/lib/scoring.ts

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,60 @@ function toNumber(value: unknown): number {
2222
return Number.isFinite(numberValue) ? numberValue : 0;
2323
}
2424

25+
function normalizeMetricKey(key: string): string {
26+
return key
27+
.trim()
28+
.toLowerCase()
29+
.replace(/[\s-]+/g, "_");
30+
}
31+
32+
function getMetricValueForUnit(
33+
unit: string | undefined,
34+
metrics: Record<string, unknown>
35+
): number | undefined {
36+
if (!unit) {
37+
return undefined;
38+
}
39+
40+
// Fast path: exact key as configured.
41+
if (metrics[unit] !== undefined) {
42+
return toNumber(metrics[unit]);
43+
}
44+
45+
const normalizedUnit = normalizeMetricKey(unit);
46+
47+
// Common canonical aliases used across ingestion paths.
48+
const canonicalAliases: Record<string, string[]> = {
49+
miles: ["distance_miles", "mile", "distance_mile"],
50+
kilometers: ["distance_km", "distance_kilometers", "km", "kilometres", "kilometer", "kilometre"],
51+
minutes: ["duration_minutes", "moving_minutes", "minute"],
52+
count: ["counts", "instances", "instance"],
53+
completion: ["completed", "is_completed"],
54+
full_days: ["full_day"],
55+
half_days: ["half_day"],
56+
};
57+
58+
const candidates = new Set<string>([normalizedUnit]);
59+
const singular = normalizedUnit.endsWith("s") ? normalizedUnit.slice(0, -1) : normalizedUnit;
60+
const plural = normalizedUnit.endsWith("s") ? normalizedUnit : `${normalizedUnit}s`;
61+
candidates.add(singular);
62+
candidates.add(plural);
63+
64+
const aliasKeys = canonicalAliases[normalizedUnit] ?? canonicalAliases[singular] ?? [];
65+
for (const alias of aliasKeys) {
66+
candidates.add(alias);
67+
}
68+
69+
for (const [key, value] of Object.entries(metrics)) {
70+
const normalizedKey = normalizeMetricKey(key);
71+
if (candidates.has(normalizedKey) && value !== undefined) {
72+
return toNumber(value);
73+
}
74+
}
75+
76+
return undefined;
77+
}
78+
2579
function getScoringConfig(activityType: Doc<"activityTypes">): Record<string, unknown> {
2680
return (activityType.scoringConfig as Record<string, unknown>) ?? {};
2781
}
@@ -36,11 +90,11 @@ async function calculateDefaultPoints(
3690
const config = getScoringConfig(activityType);
3791
const { unit, pointsPerUnit = 1, basePoints = 0 } = config;
3892

39-
if (!unit || !context.metrics[unit as string]) {
93+
const unitValue = getMetricValueForUnit(unit as string | undefined, context.metrics);
94+
if (unitValue === undefined) {
4095
return toNumber(basePoints);
4196
}
4297

43-
const unitValue = toNumber(context.metrics[unit as string]);
4498
return toNumber(basePoints) + unitValue * toNumber(pointsPerUnit);
4599
}
46100

@@ -210,11 +264,11 @@ function calculateVariantPoints(
210264
const { unit, pointsPerUnit = 1, basePoints = 0 } = variantConfig;
211265
const scoringUnit = unit || mainConfig["unit"];
212266

213-
if (!scoringUnit || !metrics[scoringUnit as string]) {
267+
const unitValue = getMetricValueForUnit(scoringUnit as string | undefined, metrics);
268+
if (unitValue === undefined) {
214269
return toNumber(basePoints);
215270
}
216271

217-
const unitValue = toNumber(metrics[scoringUnit as string]);
218272
return toNumber(basePoints) + unitValue * toNumber(pointsPerUnit);
219273
}
220274

@@ -312,18 +366,19 @@ function calculateUnitBasedPoints(
312366
const maxUnits = config["maxUnits"] as number | undefined;
313367
const basePoints = toNumber(config["basePoints"] ?? 0);
314368

315-
if (!unit || !context.metrics[unit]) {
369+
const unitValue = getMetricValueForUnit(unit, context.metrics);
370+
if (unitValue === undefined) {
316371
return basePoints;
317372
}
318373

319-
let unitValue = toNumber(context.metrics[unit]);
374+
let boundedUnitValue = unitValue;
320375

321376
// Apply cap if maxUnits is defined
322-
if (maxUnits !== undefined && unitValue > maxUnits) {
323-
unitValue = maxUnits;
377+
if (maxUnits !== undefined && boundedUnitValue > maxUnits) {
378+
boundedUnitValue = maxUnits;
324379
}
325380

326-
return basePoints + unitValue * pointsPerUnit;
381+
return basePoints + boundedUnitValue * pointsPerUnit;
327382
}
328383

329384
/**

0 commit comments

Comments
 (0)