diff --git a/app/components/vacancies-chart.tsx b/app/components/vacancies-chart.tsx index a8d3b27..6ef09ea 100644 --- a/app/components/vacancies-chart.tsx +++ b/app/components/vacancies-chart.tsx @@ -1,73 +1,91 @@ import { useCallback, useMemo, useState } from 'react' -import { ALL_KEYWORDS, Keyword, KEYWORDS } from '~/services/vacancies/vacancies.constants' -import { VacancyData } from '~/services/vacancies/vacancies.types' +import { KeywordsResponse, VacancyData } from '~/services/vacancies/vacancies.types' import { AreaChart, MultiSelect, MultiSelectItem, Select, SelectItem } from '@tremor/react' import { useSearchParams } from '@remix-run/react' type Props = { - data: VacancyData + data?: VacancyData + keywords?: KeywordsResponse } -const presets = { - 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) { +export function VacanciesChart({ data, keywords }: Props) { 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( () => searchParams.get('categories')?.split(',') || [], [searchParams] ) - const setSelectedCategories = useCallback((value: Keyword[]) => { - if (value.length === 0) { - searchParams.delete('categories') - } else { - searchParams.set('categories', value.join(',')) - } + const categoriesForChart = useMemo(() => { + return data?.categories.filter(category => selectedCategories.includes(category)) ?? [] + }, [selectedCategories, data?.categories]) - setSearchParams(searchParams) - }, []) + const setSelectedCategories = useCallback( + (value: string[]) => { + if (value.length === 0) { + searchParams.delete('categories') + } else { + searchParams.set('categories', value.join(',')) + } - const sortedCategories = useMemo( - () => sortCategoriesByVacancies(selectedCategories, data), - [selectedCategories, data] + setSearchParams(searchParams) + }, + [searchParams, setSearchParams] ) const filteredData = useMemo( () => - data.filter(row => { + data?.data?.filter(row => { for (const category of selectedCategories) { - // @ts-expect-error - if (row[category] > 0) { + const value = row[category] + if (typeof value === 'number' && value > 0) { 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 => ( + + )) + }, [keywords?.allKeywords]) + + const presetSelectItems = useMemo(() => { + return presetsForSelect.map(category => ( + + )) + }, [presetsForSelect]) + return (
@@ -80,12 +98,10 @@ export function VacanciesChart({ data }: Props) { setSelectedCategories(value)} + onValueChange={handleKeywordsChange} value={selectedCategories} > - {ALL_KEYWORDS.map(category => ( - - ))} + {multiSelectItems}
@@ -99,14 +115,9 @@ export function VacanciesChart({ data }: Props) { value={preset} id={'presets'} defaultValue={'All'} - onValueChange={value => { - setPreset(value as keyof typeof presets) - setSelectedCategories(presets[value as keyof typeof presets] as Keyword[]) - }} + onValueChange={handlePresetChange} > - {presetsForSelect.map(category => ( - - ))} + {presetSelectItems}
@@ -115,7 +126,7 @@ export function VacanciesChart({ data }: Props) { className="h-full" data={filteredData} index="date" - categories={sortedCategories} + categories={categoriesForChart} yAxisWidth={60} startEndOnly={false} intervalType="preserveStartEnd" @@ -124,23 +135,3 @@ export function VacanciesChart({ data }: Props) { ) } - -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 - }) -} diff --git a/app/routes/_index.tsx b/app/routes/_index.tsx index ae65898..c9ba578 100644 --- a/app/routes/_index.tsx +++ b/app/routes/_index.tsx @@ -8,15 +8,22 @@ export const shouldRevalidate: ShouldRevalidateFunction = ({ nextParams }) => { return !nextParams } 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 () => { - const vacancies = await vacanciesService.getAggregateByCreatedAt() - return json({ vacancies }) + const promises = [ + vacanciesService.getAggregateByCreatedAt(), + vacanciesService.getKeywords(), + ] as const + const [vacancies, keywords] = await Promise.all(promises) + return json({ vacancies, keywords }) } export default function Index() { - const { vacancies } = useLoaderData() - return + const { vacancies, keywords } = useLoaderData() + return } diff --git a/app/services/vacancies/vacancies.constants.ts b/app/services/vacancies/vacancies.constants.ts deleted file mode 100644 index f50a9af..0000000 --- a/app/services/vacancies/vacancies.constants.ts +++ /dev/null @@ -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] diff --git a/app/services/vacancies/vacancies.service.ts b/app/services/vacancies/vacancies.service.ts index d18cbca..8d73825 100644 --- a/app/services/vacancies/vacancies.service.ts +++ b/app/services/vacancies/vacancies.service.ts @@ -1,7 +1,7 @@ -import { Vacancies, VacancyData } from '~/services/vacancies/vacancies.types' +import { KeywordsResponse, Vacancies, VacancyData } from '~/services/vacancies/vacancies.types' 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 { return await fetch(`${this.baseUrl}/vacancies`).then(res => res.json()) @@ -13,13 +13,21 @@ export class VacanciesService { .then(this.formatDateOnData) } - formatDateOnData(data: VacancyData): VacancyData { - return data.map(item => { + async getKeywords(): Promise { + return await fetch(`${this.baseUrl}/vacancies/keywords`).then(res => res.json()) + } + + private formatDateOnData(data: VacancyData): VacancyData { + const mapped = data.data.map(item => { return { ...item, date: new Date(item.date).toLocaleDateString('ru'), } }) + return { + ...data, + data: mapped, + } } } diff --git a/app/services/vacancies/vacancies.types.ts b/app/services/vacancies/vacancies.types.ts index 829d317..ab2617f 100644 --- a/app/services/vacancies/vacancies.types.ts +++ b/app/services/vacancies/vacancies.types.ts @@ -8,4 +8,13 @@ export interface VacancyDataEntry { 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 + presets: Record +}