Skip to content
Closed
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions app/components/Package/HealthScore.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
<script setup lang="ts">
interface HealthScoreDimension {
score: number
weight: number
}

interface HealthScoreResponse {
package: string
version: string
score: number
grade: 'A' | 'B' | 'C' | 'D' | 'F'
dimensions: {
maintenance: HealthScoreDimension
quality: HealthScoreDimension
security: HealthScoreDimension
popularity: HealthScoreDimension
}
analyzedAt: string
}

const props = defineProps<{
packageName: string
version?: string
}>()

const { data, status } = useFetch<HealthScoreResponse>(
() => {
const base = `https://npm-pulse.vercel.app/api/v1/score/${props.packageName}`
return props.version ? `${base}?version=${props.version}` : base
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{
key: () => `health-score-${props.packageName}-${props.version ?? 'latest'}`,
server: false,
lazy: true,
},
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const isLoading = computed(() => status.value === 'pending' || status.value === 'idle')
const isError = computed(() => status.value === 'error')

function gradeColor(grade: string | undefined): string {
switch (grade) {
case 'A': return 'text-emerald-500'
case 'B': return 'text-lime-500'
case 'C': return 'text-amber-500'
case 'D': return 'text-orange-500'
case 'F': return 'text-red-500'
default: return 'text-fg-subtle'
}
}

function scoreBarColor(score: number): string {
if (score >= 80) return 'bg-emerald-500'
if (score >= 60) return 'bg-lime-500'
if (score >= 40) return 'bg-amber-500'
if (score >= 20) return 'bg-orange-500'
return 'bg-red-500'
}

const dimensions = computed(() => {
if (!data.value?.dimensions) return []
const d = data.value.dimensions
return [
{ key: 'maintenance', label: $t('package.health_score.dimension_maintenance'), score: d.maintenance?.score ?? 0, weight: d.maintenance?.weight ?? 0 },
{ key: 'quality', label: $t('package.health_score.dimension_quality'), score: d.quality?.score ?? 0, weight: d.quality?.weight ?? 0 },
{ key: 'security', label: $t('package.health_score.dimension_security'), score: d.security?.score ?? 0, weight: d.security?.weight ?? 0 },
{ key: 'popularity', label: $t('package.health_score.dimension_popularity'), score: d.popularity?.score ?? 0, weight: d.popularity?.weight ?? 0 },
]
Comment on lines +66 to +94
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Clamp score values to 0–100 before binding to progress UI.

Scores from a remote API are trusted as-is. If a value is out of range, aria-valuenow and width can become invalid (>100 or <0), causing accessibility and rendering issues.

Suggested fix
+function clampScore(score: number): number {
+  if (!Number.isFinite(score))
+    return 0
+  return Math.max(0, Math.min(100, score))
+}
+
 const dimensions = computed(() => {
   if (!data.value?.dimensions) return []
   const d = data.value.dimensions
   return [
-    { key: 'maintenance', label: $t('package.health_score.dimension_maintenance'), score: d.maintenance?.score ?? 0, weight: d.maintenance?.weight ?? 0 },
-    { key: 'quality', label: $t('package.health_score.dimension_quality'), score: d.quality?.score ?? 0, weight: d.quality?.weight ?? 0 },
-    { key: 'security', label: $t('package.health_score.dimension_security'), score: d.security?.score ?? 0, weight: d.security?.weight ?? 0 },
-    { key: 'popularity', label: $t('package.health_score.dimension_popularity'), score: d.popularity?.score ?? 0, weight: d.popularity?.weight ?? 0 },
+    { key: 'maintenance', label: $t('package.health_score.dimension_maintenance'), score: clampScore(d.maintenance?.score ?? 0), weight: d.maintenance?.weight ?? 0 },
+    { key: 'quality', label: $t('package.health_score.dimension_quality'), score: clampScore(d.quality?.score ?? 0), weight: d.quality?.weight ?? 0 },
+    { key: 'security', label: $t('package.health_score.dimension_security'), score: clampScore(d.security?.score ?? 0), weight: d.security?.weight ?? 0 },
+    { key: 'popularity', label: $t('package.health_score.dimension_popularity'), score: clampScore(d.popularity?.score ?? 0), weight: d.popularity?.weight ?? 0 },
   ]
 })

Also applies to: 134-143

})
</script>

<template>
<section aria-labelledby="health-score-heading">
<h2
id="health-score-heading"
class="text-xs text-fg-subtle uppercase tracking-wider mb-3 flex items-center gap-1.5"
>
<span class="i-lucide:activity w-3.5 h-3.5" aria-hidden="true" />
{{ $t('package.health_score.title') }}
</h2>

<!-- Loading state -->
<div v-if="isLoading" class="flex items-center gap-2 text-fg-subtle text-sm">
<span class="i-svg-spinners:ring-resize w-4 h-4" aria-hidden="true" />
<span>{{ $t('package.health_score.loading') }}</span>
</div>

<!-- Error state -->
<div v-else-if="isError" class="flex items-center gap-2 text-fg-subtle text-sm">
<span class="i-lucide:circle-alert w-4 h-4" aria-hidden="true" />
<span>{{ $t('package.health_score.error') }}</span>
</div>

<!-- Score display -->
<div v-else-if="data" class="space-y-3">
<!-- Score header: large score + grade badge -->
<div class="flex items-center gap-3">
<TooltipApp :text="$t('package.health_score.score_tooltip')" strategy="fixed">
<div class="flex items-baseline gap-1 cursor-default" tabindex="0">
<span class="font-mono text-2xl font-bold text-fg leading-none">{{ data.score }}</span>
<span class="text-xs text-fg-subtle">/100</span>
</div>
</TooltipApp>

<TooltipApp
:text="$t('package.health_score.grade_tooltip', { grade: data.grade })"
strategy="fixed"
>
<TagStatic
tabindex="0"
:class="gradeColor(data.grade)"
class="font-mono font-bold text-sm! min-w-8 justify-center"
variant="ghost"
>
{{ data.grade }}
</TagStatic>
</TooltipApp>
</div>

<!-- Dimension bars -->
<ul
class="space-y-2 list-none m-0 p-0"
:aria-label="$t('package.health_score.dimensions_label')"
>
<li v-for="dim in dimensions" :key="dim.key">
<div class="flex items-center justify-between mb-0.5">
<span class="text-xs text-fg-subtle">{{ dim.label }}</span>
<span class="font-mono text-xs text-fg-muted">{{ dim.score }}</span>
</div>
<div
class="h-1.5 w-full rounded-full overflow-hidden"
style="background-color: var(--border)"
role="progressbar"
:aria-valuenow="dim.score"
aria-valuemin="0"
aria-valuemax="100"
:aria-label="`${dim.label}: ${dim.score}/100`"
>
<div
class="h-full rounded-full transition-all duration-500"
:class="scoreBarColor(dim.score)"
:style="{ width: `${dim.score}%` }"
/>
</div>
</li>
</ul>

<!-- Footer link -->
<a
:href="`https://npm-pulse.vercel.app/api/v1/score/${props.packageName}`"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-xs text-fg-subtle hover:text-fg transition-colors duration-150 underline underline-offset-2 decoration-fg-subtle/40"
>
{{ $t('package.health_score.powered_by') }}
<span class="i-lucide:external-link w-3 h-3" aria-hidden="true" />
</a>
</div>
</section>
</template>
6 changes: 6 additions & 0 deletions app/pages/package/[[org]]/[name].vue
Original file line number Diff line number Diff line change
Expand Up @@ -926,6 +926,12 @@ const showSkeleton = shallowRef(false)
</template>
</ClientOnly>

<!-- Health Score (npm Pulse) -->
<PackageHealthScore
:package-name="pkg.name"
:version="resolvedVersion || undefined"
/>

<!-- Download stats -->
<PackageWeeklyDownloadStats
:packageName
Expand Down
13 changes: 13 additions & 0 deletions i18n/locales/ar-EG.json
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,19 @@
"download": {
"button": "تحميل",
"tarball": "تحميل ملف Tarball بصيغة .tar.gz"
},
"health_score": {
"title": "درجة الصحة",
"loading": "جارِ تحليل صحة الحزمة...",
"error": "تعذّر تحميل درجة الصحة",
"score_tooltip": "درجة صحة الحزمة الإجمالية (0-100) مدعومة من npm Pulse",
"grade_tooltip": "تقدير الصحة: {grade}",
"dimensions_label": "أبعاد درجة الصحة",
"dimension_maintenance": "الصيانة",
"dimension_quality": "الجودة",
"dimension_security": "الأمان",
"dimension_popularity": "الانتشار",
"powered_by": "npm Pulse"
}
},
"claim": {
Expand Down
Loading
Loading