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
+}