initial commit

This commit is contained in:
2024-06-09 14:52:51 +02:00
parent a71467fec5
commit c1b817a128
21 changed files with 8371 additions and 51 deletions

View File

@@ -0,0 +1,146 @@
import { useCallback, useMemo, useState } from 'react'
import { ALL_KEYWORDS, Keyword, KEYWORDS } from '~/services/vacancies/vacancies.constants'
import { 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
}
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) {
const [searchParams, setSearchParams] = useSearchParams()
const [preset, setPreset] = useState('None' as keyof typeof 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(','))
}
setSearchParams(searchParams)
}, [])
const sortedCategories = useMemo(
() => sortCategoriesByVacancies(selectedCategories, data),
[selectedCategories, data]
)
const filteredData = useMemo(
() =>
data.filter(row => {
for (const category of selectedCategories) {
// @ts-expect-error
if (row[category] > 0) {
return true
}
}
}),
[data, selectedCategories]
)
return (
<div className={'flex h-full flex-col gap-6 p-8'}>
<div className={'flex gap-4'}>
<div className="max-w-xl">
<label
htmlFor="categories"
className="text-tremor-default text-tremor-content dark:text-dark-tremor-content"
>
Technologies to compare
</label>
<MultiSelect
id={'categories'}
onValueChange={value => setSelectedCategories(value)}
value={selectedCategories}
>
{ALL_KEYWORDS.map(category => (
<MultiSelectItem key={'category-select-' + category} value={category} />
))}
</MultiSelect>
</div>
<div className="max-w-xl">
<label
htmlFor="presets"
className="text-tremor-default text-tremor-content dark:text-dark-tremor-content"
>
Preset
</label>
<Select
value={preset}
id={'presets'}
defaultValue={'All'}
onValueChange={value => {
setPreset(value as keyof typeof presets)
setSelectedCategories(presets[value as keyof typeof presets] as Keyword[])
}}
>
{presetsForSelect.map(category => (
<SelectItem key={category.label} value={category.label} />
))}
</Select>
</div>
</div>
<AreaChart
connectNulls={true}
className="h-full"
data={filteredData}
index="date"
categories={sortedCategories}
yAxisWidth={60}
startEndOnly={false}
intervalType="preserveStartEnd"
showAnimation
/>
</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
})
}

6
app/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import clsx, { type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cx(...args: ClassValue[]) {
return twMerge(clsx(...args))
}

View File

@@ -1,29 +1,28 @@
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "@remix-run/react";
import { LinksFunction } from '@remix-run/node'
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from '@remix-run/react'
import stylesheet from '~/tailwind.css?url'
import { PropsWithChildren } from 'react'
export function Layout({ children }: { children: React.ReactNode }) {
export const links: LinksFunction = () => [{ rel: 'stylesheet', href: stylesheet }]
export function Layout({ children }: PropsWithChildren) {
return (
<html lang="en">
<html lang="en" className="antialiased dark:bg-gray-950 dark:text-slate-50 h-full">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
<body className={'h-full'}>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
)
}
export default function App() {
return <Outlet />;
return <Outlet />
}

View File

@@ -1,41 +1,18 @@
import type { MetaFunction } from "@remix-run/node";
import { json, MetaFunction } from '@remix-run/node'
import { vacanciesService } from '~/services/vacancies/vacancies.service'
import { useLoaderData } from '@remix-run/react'
import { VacanciesChart } from '~/components/vacancies-chart'
export const meta: MetaFunction = () => {
return [
{ title: "New Remix App" },
{ name: "description", content: "Welcome to Remix!" },
];
};
return [{ title: 'New Remix App' }, { name: 'description', content: 'Welcome to Remix!' }]
}
export const loader = async () => {
const vacancies = await vacanciesService.getAggregateByCreatedAt()
return json({ vacancies })
}
export default function Index() {
return (
<div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.8" }}>
<h1>Welcome to Remix</h1>
<ul>
<li>
<a
target="_blank"
href="https://remix.run/start/quickstart"
rel="noreferrer"
>
5m Quick Start
</a>
</li>
<li>
<a
target="_blank"
href="https://remix.run/start/tutorial"
rel="noreferrer"
>
30m Tutorial
</a>
</li>
<li>
<a target="_blank" href="https://remix.run/docs" rel="noreferrer">
Remix Docs
</a>
</li>
</ul>
</div>
);
const { vacancies } = useLoaderData<typeof loader>()
return <VacanciesChart data={vacancies} />
}

View File

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

View File

@@ -0,0 +1,23 @@
import { Vacancies, VacancyData } from '~/services/vacancies/vacancies.types'
export class VacanciesService {
async getAll(): Promise<Vacancies> {
return await fetch('http://localhost:4321/vacancies').then(res => res.json())
}
async getAggregateByCreatedAt(): Promise<VacancyData> {
return await fetch('http://localhost:4321/vacancies/aggregated')
.then(res => res.json())
.then(this.formatDateOnData)
}
formatDateOnData(data: VacancyData): VacancyData {
return data.map(item => {
return {
...item,
date: new Date(item.date).toLocaleTimeString('ru'),
}
})
}
}
export const vacanciesService = new VacanciesService()

View File

@@ -0,0 +1,11 @@
export type Vacancies = VacancyDataEntry[]
export interface VacancyDataEntry {
createdAt: string
id: number
technology: string
updatedAt: string
vacancies: number
}
export type VacancyData = Array<{ date: string; [key: string]: string | number }>

10
app/tailwind.css Normal file
View File

@@ -0,0 +1,10 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@font-face {
font-family: 'Geist Sans';
src: url('/fonts/GeistVariableVF.woff2') format('woff2');
font-style: normal;
font-weight: 100 900;
}