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

View file

@ -1,22 +1,31 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { OpenessRating, TechnologyRating, PrivacyRating } from '~/types' import type { ProjectRating } from '~/types'
// Define props for score // Define props for score
const props = defineProps<{ const props = defineProps<{
rating: TechnologyRating | OpenessRating | PrivacyRating rating: ProjectRating
type: string percentage: number
score: number
compact?: boolean compact?: boolean
}>() }>()
// Determine the color by score const colors = [
const colorByScore = props.score <= 1 '#ff0000', // 0-10%
? 'bg-app-bg-rating-red' '#ff4500', // 11-20%
: props.score === 2 '#ff8c00', // 21-30%
? 'bg-app-bg-rating-orange' '#ffd700', // 31-40%
: props.score === 3 '#adff2f', // 41-50%
? 'bg-app-bg-rating-yellow' '#7fff00', // 51-60%
: 'bg-app-bg-rating-green' '#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) const isPopoverVisible = ref(false)
@ -34,40 +43,6 @@ const hidePopover = () => {
isPopoverVisible.value = false isPopoverVisible.value = false
}, 100) // Delay of 200ms before hiding }, 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> </script>
<template> <template>
@ -76,21 +51,18 @@ function items(): { label: string, condition: boolean, positive: string, negativ
<div <div
flex flex
items-center items-center
gap-4px
p-12px p-12px
gap-4px
hover:bg-app-bg-rating-hover hover:bg-app-bg-rating-hover
hover:rounded-8px hover:rounded-8px
@mouseenter="showPopover" @mouseenter="showPopover"
@mouseleave="hidePopover" @mouseleave="hidePopover"
> >
<!-- Render the score points -->
<div <div
v-for="point of 5" v-for="point of [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]"
:key="point" :key="point"
w="10px lg:12px" :style="`background-color: ${percentage >= point * 10 ? backgroundColorByScore : '#494949'}`"
h="10px lg:12px" :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']"
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']"
/> />
</div> </div>
@ -117,7 +89,7 @@ function items(): { label: string, condition: boolean, positive: string, negativ
@mouseleave="hidePopover" @mouseleave="hidePopover"
> >
<div <div
v-for="item in items()" v-for="item in rating.items"
:key="item.label" :key="item.label"
flex flex
justify-between justify-between
@ -125,7 +97,7 @@ function items(): { label: string, condition: boolean, positive: string, negativ
text-12px text-12px
font-700 font-700
leading-20px leading-20px
:class="{ 'text-app-text-rating-negative': !item.condition }" :class="{ 'text-app-text-rating-negative': !item.isValid }"
> >
<div <div
flex flex
@ -133,24 +105,24 @@ function items(): { label: string, condition: boolean, positive: string, negativ
gap-6px gap-6px
> >
<div <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 text-20px
mt--4px mt--4px
/> />
{{ item.label }} {{ item.label }}
</div> </div>
<NuxtLink <NuxtLink
v-if="item.link" v-if="item.positive === 'Link'"
:to="item.link" :to="item.value"
target="_blank" target="_blank"
external external
underline underline
@click.stop @click.stop
> >
Link {{ item.positive }}
</NuxtLink> </NuxtLink>
<div v-else> <div v-else>
{{ item.condition ? item.positive : item.negative }} {{ item.isValid ? item.positive : item.negative }}
</div> </div>
</div> </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 { Asset } from '~/types/asset'
import type { Ecosystem } from '~/types/ecosystem' import type { Ecosystem } from '~/types/ecosystem'
import type { Feature } from '~/types/feature' import type { Feature } from '~/types/feature'
import type { Rank } from '~/types/rank'
import type { Usecase } from '~/types/usecase' import type { Usecase } from '~/types/usecase'
export const useData = defineStore('data', () => { export const useData = defineStore('data', () => {
@ -11,21 +12,34 @@ export const useData = defineStore('data', () => {
const assets = useState<Asset[]>('assets') const assets = useState<Asset[]>('assets')
const ecosystems = useState<Ecosystem[]>('ecosystems') const ecosystems = useState<Ecosystem[]>('ecosystems')
const projects = useState<Project[]>('projects') const projects = useState<Project[]>('projects')
const ranks = useState<Rank[]>('ranks')
const selectedCategoryId = useState(() => 'all') const selectedCategoryId = useState(() => 'all')
const selectedUsecaseId = useState(() => 'all')
const selectedEcosystemId = useState(() => 'all')
const selectedAssetsUsedId = useState(() => 'all')
const selectedFeaturesId = useState(() => 'all')
const filter = reactive({ const filter = reactive({
query: '', query: '',
sortby: 'atoz', sortby: 'score',
sortDirection: 'desc',
}) })
const switcher = ref(true) const switcher = ref(true)
watch(selectedCategoryId, () => { watch([selectedCategoryId, selectedUsecaseId, selectedEcosystemId, selectedAssetsUsedId, selectedFeaturesId], () => {
if (selectedCategoryId.value !== 'all') if (selectedCategoryId.value !== 'all' || selectedUsecaseId.value !== 'all' || selectedEcosystemId.value !== 'all' || selectedAssetsUsedId.value !== 'all' || selectedFeaturesId.value !== 'all')
filter.query = '' filter.query = ''
}) })
watch(filter, () => { watch(filter, () => {
if (filter.query !== '') if (filter.query !== '') {
selectedCategoryId.value = 'all' selectedCategoryId.value = 'all'
selectedUsecaseId.value = 'all'
selectedEcosystemId.value = 'all'
selectedAssetsUsedId.value = 'all'
selectedFeaturesId.value = 'all'
}
}) })
const fetchData = async () => { const fetchData = async () => {
@ -37,32 +51,11 @@ export const useData = defineStore('data', () => {
ecosystems: Ecosystem[] ecosystems: Ecosystem[]
assets: Asset[] assets: Asset[]
features: Feature[] features: Feature[]
ranks: Rank[]
}>('/api/data') }>('/api/data')
projects.value = data.projects.map(project => ({ projects.value = data.projects.map(project => ({
...project, ...project,
ratings: { ratings: generateProjectRating(project),
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',
},
},
})).filter(p => p.name) })).filter(p => p.name)
categories.value = data.categories.map((c) => { categories.value = data.categories.map((c) => {
c.projectsCount = projects.value.filter(p => c.projectsCount = projects.value.filter(p =>
@ -74,12 +67,12 @@ export const useData = defineStore('data', () => {
ecosystems.value = data.ecosystems ecosystems.value = data.ecosystems
assets.value = data.assets assets.value = data.assets
features.value = data.features features.value = data.features
ranks.value = data.ranks
} }
catch (e) { catch (e) {
console.error(e) console.error(e)
return false return false
} }
return true return true
} }
@ -98,7 +91,7 @@ export const useData = defineStore('data', () => {
id: project.id, id: project.id,
title1: project.name, title1: project.name,
description: project.description ?? 'N/A', 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, forum: project.links?.forum,
explorer: project.links?.block_explorer, explorer: project.links?.block_explorer,
twitter: project.links?.twitter, twitter: project.links?.twitter,
@ -114,38 +107,32 @@ export const useData = defineStore('data', () => {
image: project.logos?.[0]?.url ?? '', image: project.logos?.[0]?.url ?? '',
anonymity: true, anonymity: true,
categories: project.categories, categories: project.categories,
ratings: { usecases: project.usecases,
openess: { ecosystem: project.ecosystem,
documentation: 'Link', assets_used: project.assets_used,
funding: 0, ratings: project.ratings,
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',
},
},
} }
} }
const shallowProjects = computed(() => projects.value.map(project => projectToShallow(project))) const shallowProjects = computed(() => projects.value.map(project => projectToShallow(project)))
const getProjectsByCategory = <T extends ProjectShallow>(id: string, options?: { shallow: boolean }): T[] => { const getProjectsByFilters = <T extends ProjectShallow>(options?: { shallow: boolean }): T[] => {
if (id === 'all') const filteredProjects = projects.value
return projects.value.map(project => projectToShallow(project)) as T[] .filter(project =>
else selectedCategoryId.value !== 'all' ? project.categories.includes(selectedCategoryId.value) : true,
return projects.value.filter(project => project.categories?.includes(id)).map(project => options?.shallow ? projectToShallow(project) : project) as T[] )
.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 => { 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 query = filter.query.toLowerCase()
const filteredShallowProjects = getProjectsByCategory(selectedCategoryId.value, { shallow: true }) const filteredShallowProjects = getProjectsByFilters({ shallow: true })
.filter((project) => { .filter((project) => {
return ( return (
project project
@ -173,9 +160,23 @@ export const useData = defineStore('data', () => {
return true return true
}).sort((a, b) => { }).sort((a, b) => {
if (filter.sortby === 'score') if (filter.sortby === 'score')
return b.percentage - a.percentage if (filter.sortDirection === 'asc')
if (filter.sortby === 'atoz') return a.percentage - b.percentage
return a.title1.localeCompare(b.title1) 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 else
return 0 return 0
}) })
@ -200,8 +201,69 @@ export const useData = defineStore('data', () => {
const filteredProjectsCount = computed(() => filteredProjects.value.length) 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 { return {
selectedCategoryId, selectedCategoryId,
selectedUsecaseId,
selectedEcosystemId,
selectedAssetsUsedId,
selectedFeaturesId,
filter, filter,
switcher, switcher,
categories, categories,
@ -215,7 +277,6 @@ export const useData = defineStore('data', () => {
filteredProjectsCount, filteredProjectsCount,
fetchData, fetchData,
getProjectById, getProjectById,
getProjectsByCategory,
filteredProjects, filteredProjects,
projectToShallow, 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
}