get keywords from api to avoid duplication, optimize some code

This commit is contained in:
2024-06-20 18:57:37 +02:00
parent 819881f9d9
commit 9ecb2d6769
5 changed files with 101 additions and 157 deletions

View File

@@ -1,73 +1,91 @@
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import { ALL_KEYWORDS, Keyword, KEYWORDS } from '~/services/vacancies/vacancies.constants' import { KeywordsResponse, VacancyData } from '~/services/vacancies/vacancies.types'
import { VacancyData } from '~/services/vacancies/vacancies.types'
import { AreaChart, MultiSelect, MultiSelectItem, Select, SelectItem } from '@tremor/react' import { AreaChart, MultiSelect, MultiSelectItem, Select, SelectItem } from '@tremor/react'
import { useSearchParams } from '@remix-run/react' import { useSearchParams } from '@remix-run/react'
type Props = { type Props = {
data: VacancyData data?: VacancyData
keywords?: KeywordsResponse
} }
const presets = { export function VacanciesChart({ data, keywords }: Props) {
None: [],
All: ALL_KEYWORDS,
Backend: KEYWORDS.BACKEND,
Databases: KEYWORDS.DATABASES,
DevOps: KEYWORDS.DEVOPS,
Frontend: KEYWORDS.FRONTEND,
'Frontend Frameworks': KEYWORDS.FRONTEND_FRAMEWORK,
Mobile: KEYWORDS.MOBILE,
ORM: KEYWORDS.ORM,
Styles: KEYWORDS.STYLES,
'State Management': KEYWORDS.STATE_MANAGEMENT,
Testing: KEYWORDS.TESTING,
}
const presetsForSelect = Object.entries(presets).map(
([label, value]) =>
({
label,
value,
}) as const
)
export function VacanciesChart({ data }: Props) {
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const [preset, setPreset] = useState('None' as keyof typeof presets) const [preset, setPreset] = useState('None')
const presetsForSelect = useMemo(
() =>
Object.entries(keywords?.presets ?? {}).map(
([label, value]) =>
({
label,
value,
}) as const
),
[keywords?.presets]
)
const selectedCategories = useMemo( const selectedCategories = useMemo(
() => searchParams.get('categories')?.split(',') || [], () => searchParams.get('categories')?.split(',') || [],
[searchParams] [searchParams]
) )
const setSelectedCategories = useCallback((value: Keyword[]) => { const categoriesForChart = useMemo(() => {
if (value.length === 0) { return data?.categories.filter(category => selectedCategories.includes(category)) ?? []
searchParams.delete('categories') }, [selectedCategories, data?.categories])
} else {
searchParams.set('categories', value.join(','))
}
setSearchParams(searchParams) const setSelectedCategories = useCallback(
}, []) (value: string[]) => {
if (value.length === 0) {
searchParams.delete('categories')
} else {
searchParams.set('categories', value.join(','))
}
const sortedCategories = useMemo( setSearchParams(searchParams)
() => sortCategoriesByVacancies(selectedCategories, data), },
[selectedCategories, data] [searchParams, setSearchParams]
) )
const filteredData = useMemo( const filteredData = useMemo(
() => () =>
data.filter(row => { data?.data?.filter(row => {
for (const category of selectedCategories) { for (const category of selectedCategories) {
// @ts-expect-error const value = row[category]
if (row[category] > 0) { if (typeof value === 'number' && value > 0) {
return true return true
} }
} }
}), }) ?? [],
[data, selectedCategories] [data?.data, selectedCategories]
) )
const handlePresetChange = useCallback(
(value: string) => {
setPreset(value)
setSelectedCategories(keywords?.presets[value] ?? [])
},
[keywords?.presets, setSelectedCategories]
)
const handleKeywordsChange = useCallback(
(value: string[]) => {
setSelectedCategories(value ?? [])
},
[setSelectedCategories]
)
const multiSelectItems = useMemo(() => {
return keywords?.allKeywords.map(category => (
<MultiSelectItem key={'category-select-' + category} value={category} />
))
}, [keywords?.allKeywords])
const presetSelectItems = useMemo(() => {
return presetsForSelect.map(category => (
<SelectItem key={'preset-select-item-' + category.label} value={category.label} />
))
}, [presetsForSelect])
return ( return (
<div className={'flex h-full flex-col gap-6 p-8'}> <div className={'flex h-full flex-col gap-6 p-8'}>
<div className={'flex gap-4'}> <div className={'flex gap-4'}>
@@ -80,12 +98,10 @@ export function VacanciesChart({ data }: Props) {
</label> </label>
<MultiSelect <MultiSelect
id={'categories'} id={'categories'}
onValueChange={value => setSelectedCategories(value)} onValueChange={handleKeywordsChange}
value={selectedCategories} value={selectedCategories}
> >
{ALL_KEYWORDS.map(category => ( {multiSelectItems}
<MultiSelectItem key={'category-select-' + category} value={category} />
))}
</MultiSelect> </MultiSelect>
</div> </div>
<div className="max-w-xl"> <div className="max-w-xl">
@@ -99,14 +115,9 @@ export function VacanciesChart({ data }: Props) {
value={preset} value={preset}
id={'presets'} id={'presets'}
defaultValue={'All'} defaultValue={'All'}
onValueChange={value => { onValueChange={handlePresetChange}
setPreset(value as keyof typeof presets)
setSelectedCategories(presets[value as keyof typeof presets] as Keyword[])
}}
> >
{presetsForSelect.map(category => ( {presetSelectItems}
<SelectItem key={category.label} value={category.label} />
))}
</Select> </Select>
</div> </div>
</div> </div>
@@ -115,7 +126,7 @@ export function VacanciesChart({ data }: Props) {
className="h-full" className="h-full"
data={filteredData} data={filteredData}
index="date" index="date"
categories={sortedCategories} categories={categoriesForChart}
yAxisWidth={60} yAxisWidth={60}
startEndOnly={false} startEndOnly={false}
intervalType="preserveStartEnd" intervalType="preserveStartEnd"
@@ -124,23 +135,3 @@ export function VacanciesChart({ data }: Props) {
</div> </div>
) )
} }
const sortCategoriesByVacancies = (categories: string[], data: VacancyData) => {
const entryToCompare = data.at(-1)
return categories.sort((a, b) => {
if (!entryToCompare) {
return 0
}
if (entryToCompare[a] === undefined && entryToCompare[b] === undefined) {
return 0
}
if (entryToCompare[a] > entryToCompare[b]) {
return -1
}
if (entryToCompare[a] < entryToCompare[b]) {
return 1
}
return 0
})
}

View File

@@ -8,15 +8,22 @@ export const shouldRevalidate: ShouldRevalidateFunction = ({ nextParams }) => {
return !nextParams return !nextParams
} }
export const meta: MetaFunction = () => { export const meta: MetaFunction = () => {
return [{ title: 'New Remix App' }, { name: 'description', content: 'Welcome to Remix!' }] return [
{ title: 'Vacancies trends' },
{ name: 'description', content: 'See how software vacancies change over time' },
]
} }
export const loader = async () => { export const loader = async () => {
const vacancies = await vacanciesService.getAggregateByCreatedAt() const promises = [
return json({ vacancies }) vacanciesService.getAggregateByCreatedAt(),
vacanciesService.getKeywords(),
] as const
const [vacancies, keywords] = await Promise.all(promises)
return json({ vacancies, keywords })
} }
export default function Index() { export default function Index() {
const { vacancies } = useLoaderData<typeof loader>() const { vacancies, keywords } = useLoaderData<typeof loader>()
return <VacanciesChart data={vacancies} /> return <VacanciesChart data={vacancies} keywords={keywords} />
} }

View File

@@ -1,71 +0,0 @@
export const KEYWORDS = {
BACKEND: [
'node.js',
'nestjs',
'nest.js',
'go',
'.net',
'asp.net',
'java',
'express',
'django',
'laravel',
'php',
],
LANGUAGES: [
'typescript',
'javascript',
'python',
'java',
'c#',
'c++',
'c',
'php',
'ruby',
'go',
'kotlin',
'swift',
'objective-c',
],
ORM: ['typeorm', 'prisma', 'sequelize', 'drizzle'],
get FRONTEND() {
return [
...this.FRONTEND_FRAMEWORK,
...this.STYLES,
...this.STATE_MANAGEMENT,
...this.TESTING,
'fsd',
]
},
FRONTEND_FRAMEWORK: [
'html',
'nuxt',
'react',
'remix',
'angular',
'vue',
'jquery',
'svelte',
'nextjs',
'next.js',
],
STYLES: ['css', 'sass', 'tailwind', 'styled-components', 'material ui', 'mui', 'bootstrap'],
STATE_MANAGEMENT: [
'redux',
'rtk',
'redux toolkit',
'redux toolkit query',
'effector',
'react-query',
'mobx',
],
DATABASES: ['mysql', 'postgres', 'mongodb', 'redis', 'cassandra', 'sqlite', 'firebase'],
DEVOPS: ['docker', 'kubernetes', 'jenkins', 'ansible', 'terraform'],
TESTING: ['jest', 'mocha', 'cypress', 'selenium', 'playwright', 'jasmine', 'puppeteer', 'vitest'],
MOBILE: ['react native', 'flutter', 'swift', 'kotlin', 'xamarin', 'objective-c'],
TOOLS: ['webpack', 'vite', 'graphql', 'rest', 'storybook'],
} as const
export const ALL_KEYWORDS = [...new Set(Object.values(KEYWORDS).flat().sort())]
export type Keyword = (typeof ALL_KEYWORDS)[number]

View File

@@ -1,7 +1,7 @@
import { Vacancies, VacancyData } from '~/services/vacancies/vacancies.types' import { KeywordsResponse, Vacancies, VacancyData } from '~/services/vacancies/vacancies.types'
export class VacanciesService { export class VacanciesService {
baseUrl = 'https://vacancies-trends-api.andrii.es' baseUrl = process.env.VACANCIES_API_URL ?? 'https://vacancies-trends-api.andrii.es'
async getAll(): Promise<Vacancies> { async getAll(): Promise<Vacancies> {
return await fetch(`${this.baseUrl}/vacancies`).then(res => res.json()) return await fetch(`${this.baseUrl}/vacancies`).then(res => res.json())
@@ -13,13 +13,21 @@ export class VacanciesService {
.then(this.formatDateOnData) .then(this.formatDateOnData)
} }
formatDateOnData(data: VacancyData): VacancyData { async getKeywords(): Promise<KeywordsResponse> {
return data.map(item => { return await fetch(`${this.baseUrl}/vacancies/keywords`).then(res => res.json())
}
private formatDateOnData(data: VacancyData): VacancyData {
const mapped = data.data.map(item => {
return { return {
...item, ...item,
date: new Date(item.date).toLocaleDateString('ru'), date: new Date(item.date).toLocaleDateString('ru'),
} }
}) })
return {
...data,
data: mapped,
}
} }
} }

View File

@@ -8,4 +8,13 @@ export interface VacancyDataEntry {
vacancies: number vacancies: number
} }
export type VacancyData = Array<{ date: string; [key: string]: string | number }> export type VacancyData = {
categories: string[]
data: Array<{ date: string; [key: string]: string | number }>
}
export type KeywordsResponse = {
allKeywords: string[]
keywords: Record<string, string[]>
presets: Record<string, string[]>
}