feat(rating): calculate and show rating based on github data

This commit is contained in:
Daniel Klein 2024-09-17 08:11:35 +02:00
parent 04750862c5
commit 161ba35ff6
5 changed files with 298 additions and 173 deletions

View file

@ -1,12 +1,14 @@
<script lang="ts" setup>
import type { ProjectShallow } from '~/types'
import type { ProjectRating, ProjectShallow } from '~/types'
const props = defineProps<{
project: ProjectShallow
}>()
const { switcher } = storeToRefs(useData())
const { switcher, ecosystems } = storeToRefs(useData())
const projectItems = ['Swap,Mixer', { label: 'Openess', rating: props.project.ratings.openess, type: 'openess' }, { label: 'Technology', rating: props.project.ratings.technology, type: 'technology' }, { label: 'Privacy', rating: props.project.ratings.privacy, type: 'privacy' }, 'Ecosystem', 'Links']
const ratings: { label: string, type: string, rating: ProjectRating }[] = (props.project.ratings || []).map(rating => ({ label: rating.name, type: 'rating', rating: rating }))
const ecosystem: { label: string[], type: string } = { label: ecosystems.value.filter(e => (props.project.ecosystem || []).includes(e.id)).map(e => e.icon!), type: 'ecosystem' }
const projectItems: { label: string | string[], type: string, rating?: ProjectRating }[] = [{ label: props.project.usecases || [], type: 'array' }, ...ratings, ecosystem, { label: ['Links'], type: 'array' }]
</script>
<template>
@ -26,7 +28,7 @@ const projectItems = ['Swap,Mixer', { label: 'Openess', rating: props.project.ra
>
<div
col-span="1 lg:3"
col-span="1 lg:2"
flex
items-center
gap="12px lg:16px"
@ -76,62 +78,80 @@ const projectItems = ['Swap,Mixer', { label: 'Openess', rating: props.project.ra
leading-16px
lg:hidden
>
Usecases
{{ project.usecases?.join(', ') }}
</p>
</div>
</div>
<div
v-for="(projectItem, index) of projectItems"
:key="projectItem.toString()"
hidden
lg:flex
items-center
justify-start
text-14px
leading-24px
>
<p
v-if="typeof projectItem === 'string'"
text-app-text-grey
<ClientOnly>
<div
v-for="(projectItem, index) of projectItems"
:key="projectItem.label.toString()"
hidden
lg:flex
items-center
text-14px
leading-24px
:class="{ 'col-span-1 lg:col-span-2': index === 0 }"
>
{{ projectItem }}
</p>
<ProjectRating
v-else
:score="index"
:rating="projectItem.rating"
:type="projectItem.type"
/>
</div>
<div
flex
items-center
justify-end
w-full
gap-16px
>
<UnoIcon
block
lg:hidden
i-iconoir-internet
text="24px"
/>
<p
v-if="projectItem.type === 'array'"
text-app-text-grey
>
{{ (projectItem.label as string[] || []).join(', ') }}
</p>
<div
v-if="projectItem.type === 'ecosystem'"
flex
items-center
justify-start
gap-2px
>
<NuxtImg
v-for="ecosystem of projectItem.label"
:key="ecosystem"
:src="ecosystem"
w-24px
h-24px
rounded-full
/>
</div>
<ProjectRating
v-if="projectItem.type! === 'rating' && projectItem.rating"
:percentage="projectItem.rating.points"
:rating="projectItem.rating"
:type="projectItem.rating.type"
/>
</div>
<div
flex
items-center
justify-center
border="2px app-white"
text="14px md:18px"
leading="24px md:32px"
max-h-="28px md:32px"
max-w="48px md:56px"
justify-end
w-full
font-700
whitespace-nowrap
gap-16px
>
{{ project.percentage }} %
<UnoIcon
block
lg:hidden
i-iconoir-internet
text="24px"
/>
<div
flex
items-center
justify-center
border="2px app-white"
text="14px md:18px"
leading="24px md:32px"
max-h-="28px md:32px"
max-w="48px md:56px"
w-full
font-700
whitespace-nowrap
>
{{ project.percentage }} %
</div>
</div>
</div>
</ClientOnly>
</div>
</NuxtLink>

View file

@ -1,22 +1,31 @@
<script lang="ts" setup>
import type { OpenessRating, TechnologyRating, PrivacyRating } from '~/types'
import type { ProjectRating } from '~/types'
// Define props for score
const props = defineProps<{
rating: TechnologyRating | OpenessRating | PrivacyRating
type: string
score: number
rating: ProjectRating
percentage: number
compact?: boolean
}>()
// Determine the color by score
const colorByScore = props.score <= 1
? 'bg-app-bg-rating-red'
: props.score === 2
? 'bg-app-bg-rating-orange'
: props.score === 3
? 'bg-app-bg-rating-yellow'
: 'bg-app-bg-rating-green'
const colors = [
'#ff0000', // 0-10%
'#ff4500', // 11-20%
'#ff8c00', // 21-30%
'#ffd700', // 31-40%
'#adff2f', // 41-50%
'#7fff00', // 51-60%
'#00ff00', // 61-70%
'#32cd32', // 71-80%
'#00fa9a', // 81-90%
'#00ffff', // 91-100%
]
const backgroundColorByScore = computed(() => {
const normalizedPercentage = Math.min(Math.max(props.percentage, 0), 100)
const colorIndex = Math.floor(normalizedPercentage / 10)
return colors[colorIndex]
})
const isPopoverVisible = ref(false)
@ -34,40 +43,6 @@ const hidePopover = () => {
isPopoverVisible.value = false
}, 100) // Delay of 200ms before hiding
}
function currentRating<T>(): T {
if (props.type === 'privacy')
return props.rating as T
if (props.type === 'technology')
return props.rating as T
return props.rating as T
}
function items(): { label: string, condition: boolean, positive: string, negative: string, link?: string }[] {
if (props.type === 'privacy')
return [
{ label: 'KYC', condition: !!currentRating<PrivacyRating>().no_kyc, positive: 'No', negative: 'Yes' },
{ label: 'Compliance', condition: !!currentRating<PrivacyRating>().no_compliance, positive: 'No', negative: 'OFAC' },
{ label: 'Privacy policy', condition: !!currentRating<PrivacyRating>().policy, positive: 'Link', negative: 'Not available', link: currentRating<PrivacyRating>().policy },
{ label: 'Default privacy', condition: !!currentRating<PrivacyRating>().default_privacy, positive: 'Yes', negative: 'No' },
]
if (props.type === 'technology')
return [
{ label: 'Mainnet', condition: !!currentRating<TechnologyRating>().mainnet, positive: 'Yes', negative: 'No' },
{ label: 'Opensource', condition: !!currentRating<TechnologyRating>().opensource, positive: 'Yes', negative: 'No' },
{ label: 'Asset custody', condition: !!currentRating<TechnologyRating>().assets, positive: 'None', negative: 'Custodial' },
{ label: 'Upgradability', condition: !!currentRating<TechnologyRating>().no_pgradability, positive: 'Disabled', negative: 'Enabled' },
{ label: 'Audits', condition: currentRating<TechnologyRating>().audits >= 1, positive: `${currentRating<TechnologyRating>().audits} ${currentRating<TechnologyRating>().audits > 1 ? 'Audits' : 'Audit'}`, negative: 'None' },
]
return [
{ label: 'Documentation', condition: !!currentRating<OpenessRating>().documentation, positive: 'Link', negative: 'Not available', link: currentRating<OpenessRating>().documentation },
{ label: 'Github', condition: !!currentRating<OpenessRating>().github, positive: 'Link', negative: 'Not available', link: currentRating<OpenessRating>().github },
{ label: 'Socials', condition: !!currentRating<OpenessRating>().socials, positive: 'Link', negative: 'Not available', link: currentRating<OpenessRating>().socials },
{ label: 'Whitepaper', condition: !!currentRating<OpenessRating>().whitepaper, positive: 'Link', negative: 'Not available', link: currentRating<OpenessRating>().whitepaper },
{ label: 'Team', condition: currentRating<OpenessRating>().team >= 1, positive: `${currentRating<OpenessRating>().team} ${currentRating<OpenessRating>().team > 1 ? 'Members' : 'Member'}`, negative: 'Anonymous' },
{ label: 'Funding', condition: currentRating<OpenessRating>().funding >= 1, positive: `${currentRating<OpenessRating>().funding} ${currentRating<OpenessRating>().team > 1 ? 'Investments' : 'Investment'}`, negative: 'Not available' },
]
}
</script>
<template>
@ -76,21 +51,18 @@ function items(): { label: string, condition: boolean, positive: string, negativ
<div
flex
items-center
gap-4px
p-12px
gap-4px
hover:bg-app-bg-rating-hover
hover:rounded-8px
@mouseenter="showPopover"
@mouseleave="hidePopover"
>
<!-- Render the score points -->
<div
v-for="point of 5"
v-for="point of [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]"
:key="point"
w="10px lg:12px"
h="10px lg:12px"
rounded-2px
:class="[point <= score ? colorByScore : 'bg-app-bg-rating-default', compact ? 'h-8px lg:h-10px w-8px lg:w-10px' : 'w-10px lg:w-12px h-10px lg:h-12px']"
:style="`background-color: ${percentage >= point * 10 ? backgroundColorByScore : '#494949'}`"
:class="[compact ? 'h-8px lg:h-10px w-4px lg:w-5px' : 'w-5px lg:w-6px h-10px lg:h-12px', point % 2 === 0 ? 'rounded-l-2px' : 'rounded-r-2px ml--4px', 'bg-app-bg-rating-default']"
/>
</div>
@ -117,7 +89,7 @@ function items(): { label: string, condition: boolean, positive: string, negativ
@mouseleave="hidePopover"
>
<div
v-for="item in items()"
v-for="item in rating.items"
:key="item.label"
flex
justify-between
@ -125,7 +97,7 @@ function items(): { label: string, condition: boolean, positive: string, negativ
text-12px
font-700
leading-20px
:class="{ 'text-app-text-rating-negative': !item.condition }"
:class="{ 'text-app-text-rating-negative': !item.isValid }"
>
<div
flex
@ -133,24 +105,24 @@ function items(): { label: string, condition: boolean, positive: string, negativ
gap-6px
>
<div
:class="[item.condition ? 'i-ic-sharp-thumb-up' : 'i-ic-sharp-thumb-down']"
:class="[item.isValid ? 'i-ic-sharp-thumb-up' : 'i-ic-sharp-thumb-down']"
text-20px
mt--4px
/>
{{ item.label }}
</div>
<NuxtLink
v-if="item.link"
:to="item.link"
v-if="item.positive === 'Link'"
:to="item.value"
target="_blank"
external
underline
@click.stop
>
Link
{{ item.positive }}
</NuxtLink>
<div v-else>
{{ item.condition ? item.positive : item.negative }}
{{ item.isValid ? item.positive : item.negative }}
</div>
</div>
</div>

View file

@ -1,7 +1,8 @@
import type { Category, Project, ProjectShallow } from '~/types'
import type { Category, Project, ProjectRating, ProjectRatingItem, ProjectShallow } from '~/types'
import type { Asset } from '~/types/asset'
import type { Ecosystem } from '~/types/ecosystem'
import type { Feature } from '~/types/feature'
import type { Rank } from '~/types/rank'
import type { Usecase } from '~/types/usecase'
export const useData = defineStore('data', () => {
@ -11,21 +12,34 @@ export const useData = defineStore('data', () => {
const assets = useState<Asset[]>('assets')
const ecosystems = useState<Ecosystem[]>('ecosystems')
const projects = useState<Project[]>('projects')
const ranks = useState<Rank[]>('ranks')
const selectedCategoryId = useState(() => 'all')
const selectedUsecaseId = useState(() => 'all')
const selectedEcosystemId = useState(() => 'all')
const selectedAssetsUsedId = useState(() => 'all')
const selectedFeaturesId = useState(() => 'all')
const filter = reactive({
query: '',
sortby: 'atoz',
sortby: 'score',
sortDirection: 'desc',
})
const switcher = ref(true)
watch(selectedCategoryId, () => {
if (selectedCategoryId.value !== 'all')
watch([selectedCategoryId, selectedUsecaseId, selectedEcosystemId, selectedAssetsUsedId, selectedFeaturesId], () => {
if (selectedCategoryId.value !== 'all' || selectedUsecaseId.value !== 'all' || selectedEcosystemId.value !== 'all' || selectedAssetsUsedId.value !== 'all' || selectedFeaturesId.value !== 'all')
filter.query = ''
})
watch(filter, () => {
if (filter.query !== '')
if (filter.query !== '') {
selectedCategoryId.value = 'all'
selectedUsecaseId.value = 'all'
selectedEcosystemId.value = 'all'
selectedAssetsUsedId.value = 'all'
selectedFeaturesId.value = 'all'
}
})
const fetchData = async () => {
@ -37,32 +51,11 @@ export const useData = defineStore('data', () => {
ecosystems: Ecosystem[]
assets: Asset[]
features: Feature[]
ranks: Rank[]
}>('/api/data')
projects.value = data.projects.map(project => ({
...project,
ratings: {
openess: {
documentation: 'Link',
funding: 0,
github: 'Link',
socials: '',
team: 1,
whitepaper: '',
},
technology: {
mainnet: true,
opensource: true,
assets: false,
audits: 0,
no_pgradability: true,
},
privacy: {
no_kyc: true,
no_compliance: true,
default_privacy: false,
policy: 'Link',
},
},
ratings: generateProjectRating(project),
})).filter(p => p.name)
categories.value = data.categories.map((c) => {
c.projectsCount = projects.value.filter(p =>
@ -74,12 +67,12 @@ export const useData = defineStore('data', () => {
ecosystems.value = data.ecosystems
assets.value = data.assets
features.value = data.features
ranks.value = data.ranks
}
catch (e) {
console.error(e)
return false
}
return true
}
@ -98,7 +91,7 @@ export const useData = defineStore('data', () => {
id: project.id,
title1: project.name,
description: project.description ?? 'N/A',
percentage: Math.floor(Math.random() * 91),
percentage: Math.round((project.ratings?.reduce((a, b) => a + b.points, 0) || 0) / 1.5),
forum: project.links?.forum,
explorer: project.links?.block_explorer,
twitter: project.links?.twitter,
@ -114,38 +107,32 @@ export const useData = defineStore('data', () => {
image: project.logos?.[0]?.url ?? '',
anonymity: true,
categories: project.categories,
ratings: {
openess: {
documentation: 'Link',
funding: 0,
github: 'Link',
socials: '',
team: 1,
whitepaper: '',
},
technology: {
mainnet: true,
opensource: true,
assets: false,
audits: 0,
no_pgradability: true,
},
privacy: {
no_kyc: true,
no_compliance: true,
default_privacy: false,
policy: 'Link',
},
},
usecases: project.usecases,
ecosystem: project.ecosystem,
assets_used: project.assets_used,
ratings: project.ratings,
}
}
const shallowProjects = computed(() => projects.value.map(project => projectToShallow(project)))
const getProjectsByCategory = <T extends ProjectShallow>(id: string, options?: { shallow: boolean }): T[] => {
if (id === 'all')
return projects.value.map(project => projectToShallow(project)) as T[]
else
return projects.value.filter(project => project.categories?.includes(id)).map(project => options?.shallow ? projectToShallow(project) : project) as T[]
const getProjectsByFilters = <T extends ProjectShallow>(options?: { shallow: boolean }): T[] => {
const filteredProjects = projects.value
.filter(project =>
selectedCategoryId.value !== 'all' ? project.categories.includes(selectedCategoryId.value) : true,
)
.filter(project =>
selectedUsecaseId.value !== 'all' ? project.usecases?.map(u => u.toLowerCase()).includes(selectedUsecaseId.value.toLowerCase()) : true,
)
.filter(project =>
selectedEcosystemId.value !== 'all' ? project.ecosystem?.map(e => e.toLowerCase()).includes(selectedEcosystemId.value.toLowerCase()) : true,
)
.filter(project =>
selectedAssetsUsedId.value !== 'all' ? project.assets_used?.map(a => a.toLowerCase()).includes(selectedAssetsUsedId.value.toLowerCase()) : true,
)
.filter(project =>
selectedFeaturesId.value !== 'all' ? project.technology?.features?.map(f => f.toLowerCase()).includes(selectedFeaturesId.value.toLowerCase()) : true,
)
return (filteredProjects.map(project => options?.shallow ? projectToShallow(project) : project) as T[])
}
const getProjectById = <T extends Project | ProjectShallow>(id: string, options?: { shallow: boolean }): T => {
@ -159,7 +146,7 @@ export const useData = defineStore('data', () => {
const query = filter.query.toLowerCase()
const filteredShallowProjects = getProjectsByCategory(selectedCategoryId.value, { shallow: true })
const filteredShallowProjects = getProjectsByFilters({ shallow: true })
.filter((project) => {
return (
project
@ -173,9 +160,23 @@ export const useData = defineStore('data', () => {
return true
}).sort((a, b) => {
if (filter.sortby === 'score')
return b.percentage - a.percentage
if (filter.sortby === 'atoz')
return a.title1.localeCompare(b.title1)
if (filter.sortDirection === 'asc')
return a.percentage - b.percentage
else
return b.percentage - a.percentage
if (filter.sortby === 'title')
if (filter.sortDirection === 'asc')
return a.title1.toLowerCase().localeCompare(b.title1.toLowerCase())
else
return b.title1.toLowerCase().localeCompare(a.title1.toLowerCase())
if (filter.sortby === 'openess' || filter.sortby === 'technology' || filter.sortby === 'privacy') {
const scoreA = a.ratings?.find(r => r.type === filter.sortby)?.points || 0
const scoreB = b.ratings?.find(r => r.type === filter.sortby)?.points || 0
if (filter.sortDirection === 'asc')
return scoreB - scoreA
else
return scoreA - scoreB
}
else
return 0
})
@ -200,8 +201,69 @@ export const useData = defineStore('data', () => {
const filteredProjectsCount = computed(() => filteredProjects.value.length)
const getNestedField = (project: Project, field: string) => {
const fields = field.split('.')
return fields.reduce((acc: any, curr: string) => {
return acc && acc[curr as keyof typeof acc]
}, project)
}
const generateProjectRating = (project: Project) => {
const projectRatings: ProjectRating[] = ranks.value?.map((rank) => {
let rankPoints = 0
const ratingStats: ProjectRatingItem[] = rank.references?.map((ref) => {
let isValid = false
const field = ref.field.includes('.') ? getNestedField(project, ref.field) : project[ref.field]
let value
let positive
if (ref.condition.minLength) {
value = (field as any[])?.length
if (value) {
isValid = value >= ref.condition.minLength
positive = `${value} ${ref.positive}${value > 1 ? 's' : ''}`
}
}
if (ref.condition.equals) {
value = field
if (value)
isValid = value === ref.condition.equals
}
if (ref.condition.exists) {
value = field
if (value)
isValid = !!value
}
rankPoints += isValid ? ref.points : 0
return {
isValid,
label: ref.label,
positive: positive ? positive : ref.positive,
negative: ref.negative,
value,
}
})
return {
type: rank.id,
name: rank.name,
items: ratingStats,
points: rankPoints,
}
})
return projectRatings
}
return {
selectedCategoryId,
selectedUsecaseId,
selectedEcosystemId,
selectedAssetsUsedId,
selectedFeaturesId,
filter,
switcher,
categories,
@ -215,7 +277,6 @@ export const useData = defineStore('data', () => {
filteredProjectsCount,
fetchData,
getProjectById,
getProjectsByCategory,
filteredProjects,
projectToShallow,
}

22
types/rank.ts Normal file
View file

@ -0,0 +1,22 @@
import type { Project } from './project'
export interface Rank {
id: string
name: string
references: Reference[]
}
interface Reference {
field: keyof Project
label: string
positive: string
negative: string
condition: Condition
points: number
}
interface Condition {
minLength?: number
exists?: boolean
equals?: boolean | string
}

50
utils/score.ts Normal file
View file

@ -0,0 +1,50 @@
import type { Project, ProjectIndexable, ProjectShallow } from '~/types'
const fulfilled = (value: any): boolean => {
const type = typeof value
switch (type) {
case 'string':
if (value !== '')
return true
break
case 'object':
if (Object.keys(value!).length > 0)
return true
break
default:
return false
}
return false
}
export const calculateScore = (project: ProjectIndexable | ProjectShallow | Project) => {
const criterias: { value: keyof ProjectIndexable, key: keyof ProjectIndexable | '' }[] = [
{ value: 'product_readiness', key: '' },
{ value: 'github', key: 'links' },
{ value: 'docs', key: 'links' },
{ value: 'team', key: '' },
{ value: 'audits', key: '' },
]
let matched = 0
for (let i = 0; i < criterias.length; i++) {
let value
// value = ((criterias[i].key ?? props.project[criterias[i].value as keyof typeof props.project]) ?? null === null) ? null : (props.project as ProjectIndexable)[criterias[i].key][criterias[i].value]
const indexableProject = project as ProjectIndexable
if (criterias[i].key !== '')
value = (indexableProject[criterias[i].key] as any)?.[criterias[i].value]
else
value = indexableProject?.[criterias[i].value]
// console.log(props.project?.links?.github);
// console.log(Object.keys(props.indexableProject["team"]).length);
if (value === null || value === undefined)
continue
if (fulfilled(value))
matched++
}
return 100 / criterias.length * matched
}