mirror of
https://github.com/ershisan99/vacancies-trends-front.git
synced 2025-12-16 20:59:25 +00:00
add grouping and totals
This commit is contained in:
148
app/components/ui/chart-tooltip.tsx
Normal file
148
app/components/ui/chart-tooltip.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Color, colorPalette, getColorClassNames, tremorTwMerge } from '~/lib/tremor'
|
||||||
|
import { CustomTooltipProps } from '@tremor/react'
|
||||||
|
import { GroupByPeriod } from '~/services/vacancies/vacancies.types'
|
||||||
|
import { endOfMonth, endOfWeek, endOfYear, isSameDay, setDefaultOptions } from 'date-fns'
|
||||||
|
setDefaultOptions({ weekStartsOn: 1 })
|
||||||
|
|
||||||
|
export const ChartTooltipFrame = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div
|
||||||
|
className={tremorTwMerge(
|
||||||
|
// common
|
||||||
|
'rounded-tremor-default text-tremor-default border',
|
||||||
|
// light
|
||||||
|
'bg-tremor-background shadow-tremor-dropdown border-tremor-border',
|
||||||
|
// dark
|
||||||
|
'dark:bg-dark-tremor-background dark:shadow-dark-tremor-dropdown dark:border-dark-tremor-border'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ChartTooltipRowProps {
|
||||||
|
value: string
|
||||||
|
name: string
|
||||||
|
color: Color | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChartTooltipRow = ({ value, name, color }: ChartTooltipRowProps) => (
|
||||||
|
<div className="flex items-center justify-between space-x-8">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span
|
||||||
|
className={tremorTwMerge(
|
||||||
|
// common
|
||||||
|
'shrink-0 rounded-tremor-full border-2 h-3 w-3',
|
||||||
|
// light
|
||||||
|
'border-tremor-background shadow-tremor-card',
|
||||||
|
// dark
|
||||||
|
'dark:border-dark-tremor-background dark:shadow-dark-tremor-card',
|
||||||
|
getColorClassNames(color, colorPalette.background).bgColor
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
className={tremorTwMerge(
|
||||||
|
// commmon
|
||||||
|
'text-right whitespace-nowrap',
|
||||||
|
// light
|
||||||
|
'text-tremor-content',
|
||||||
|
// dark
|
||||||
|
'dark:text-dark-tremor-content'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className={tremorTwMerge(
|
||||||
|
// common
|
||||||
|
'font-medium tabular-nums text-right whitespace-nowrap',
|
||||||
|
// light
|
||||||
|
'text-tremor-content-emphasis',
|
||||||
|
// dark
|
||||||
|
'dark:text-dark-tremor-content-emphasis'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ChartTooltip = ({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
label,
|
||||||
|
groupBy,
|
||||||
|
}: CustomTooltipProps & {
|
||||||
|
rawDate: string
|
||||||
|
groupBy: GroupByPeriod
|
||||||
|
}) => {
|
||||||
|
if (active && payload) {
|
||||||
|
const rawDate = payload[0]?.payload?.rawDate
|
||||||
|
console.log(payload, rawDate)
|
||||||
|
const filteredPayload = payload.filter((item: any) => item.type !== 'none')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartTooltipFrame>
|
||||||
|
<div
|
||||||
|
className={tremorTwMerge(
|
||||||
|
// light
|
||||||
|
'border-tremor-border border-b px-4 py-2',
|
||||||
|
// dark
|
||||||
|
'dark:border-dark-tremor-border'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className={tremorTwMerge(
|
||||||
|
// common
|
||||||
|
'font-medium',
|
||||||
|
// light
|
||||||
|
'text-tremor-content-emphasis',
|
||||||
|
// dark
|
||||||
|
'dark:text-dark-tremor-content-emphasis'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatDate(rawDate ?? '', groupBy)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={tremorTwMerge('px-4 py-2 space-y-1')}>
|
||||||
|
{filteredPayload.map(({ value, name, color }, idx: number) => (
|
||||||
|
<ChartTooltipRow
|
||||||
|
key={`id-${idx}`}
|
||||||
|
value={value?.toString() ?? ''}
|
||||||
|
name={name?.toString() ?? ''}
|
||||||
|
color={color ?? ''}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ChartTooltipFrame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ChartTooltip }
|
||||||
|
function formatDate(date: string | number, groupBy: GroupByPeriod): string {
|
||||||
|
let endDate: Date
|
||||||
|
const startDate = new Date(date)
|
||||||
|
switch (groupBy) {
|
||||||
|
case 'day':
|
||||||
|
endDate = new Date(startDate)
|
||||||
|
break
|
||||||
|
case 'week':
|
||||||
|
endDate = endOfWeek(startDate)
|
||||||
|
break
|
||||||
|
case 'month':
|
||||||
|
endDate = endOfMonth(startDate)
|
||||||
|
break
|
||||||
|
case 'year':
|
||||||
|
endDate = new Date(endOfYear(startDate))
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
endDate = new Date(startDate)
|
||||||
|
}
|
||||||
|
return isSameDay(startDate, endDate)
|
||||||
|
? startDate.toLocaleDateString('ru')
|
||||||
|
: `${startDate.toLocaleDateString('ru')} - ${endDate.toLocaleDateString('ru')}`
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useCallback, useMemo } from 'react'
|
import { useCallback, useMemo } from 'react'
|
||||||
import { KeywordsResponse, VacancyData } from '~/services/vacancies/vacancies.types'
|
import { GroupByPeriod, KeywordsResponse, VacancyData } from '~/services/vacancies/vacancies.types'
|
||||||
import { AreaChart, MultiSelect, MultiSelectItem, Select, SelectItem } from '@tremor/react'
|
import { AreaChart, MultiSelect, MultiSelectItem, Select, SelectItem, Switch } from '@tremor/react'
|
||||||
import { useSearchParams } from '@remix-run/react'
|
import { useSearchParams } from '@remix-run/react'
|
||||||
|
import { capitalize } from 'remeda'
|
||||||
|
import { ChartTooltip } from '~/components/ui/chart-tooltip'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
data?: VacancyData
|
data?: VacancyData
|
||||||
@@ -32,6 +34,11 @@ export function VacanciesChart({ data, keywords }: Props) {
|
|||||||
return searchParams.get('preset') ?? 'None'
|
return searchParams.get('preset') ?? 'None'
|
||||||
}, [searchParams])
|
}, [searchParams])
|
||||||
|
|
||||||
|
const showTotal = useMemo(() => searchParams.get('showTotal') === 'true', [searchParams])
|
||||||
|
const groupBy: GroupByPeriod = useMemo(() => {
|
||||||
|
return (searchParams.get('groupBy') ?? 'day') as GroupByPeriod
|
||||||
|
}, [searchParams])
|
||||||
|
|
||||||
const setPreset = useCallback(
|
const setPreset = useCallback(
|
||||||
(value: string | null) => {
|
(value: string | null) => {
|
||||||
if (!value || value === 'None') {
|
if (!value || value === 'None') {
|
||||||
@@ -44,10 +51,27 @@ export function VacanciesChart({ data, keywords }: Props) {
|
|||||||
},
|
},
|
||||||
[searchParams, setSearchParams]
|
[searchParams, setSearchParams]
|
||||||
)
|
)
|
||||||
|
const setShowTotal = useCallback(
|
||||||
|
(value: boolean) => {
|
||||||
|
if (value) {
|
||||||
|
searchParams.set('showTotal', 'true')
|
||||||
|
} else {
|
||||||
|
searchParams.delete('showTotal')
|
||||||
|
}
|
||||||
|
setSearchParams(searchParams)
|
||||||
|
},
|
||||||
|
[searchParams, setSearchParams]
|
||||||
|
)
|
||||||
|
|
||||||
const categoriesForChart = useMemo(() => {
|
const categoriesForChart = useMemo(() => {
|
||||||
return data?.categories.filter(category => selectedCategories.includes(category)) ?? []
|
const filtered = [
|
||||||
}, [selectedCategories, data?.categories])
|
...(data?.categories.filter(category => selectedCategories.includes(category)) ?? []),
|
||||||
|
]
|
||||||
|
if (showTotal) {
|
||||||
|
filtered.unshift('total')
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}, [selectedCategories, data?.categories, showTotal])
|
||||||
|
|
||||||
const setSelectedCategories = useCallback(
|
const setSelectedCategories = useCallback(
|
||||||
(value: string[]) => {
|
(value: string[]) => {
|
||||||
@@ -62,17 +86,42 @@ export function VacanciesChart({ data, keywords }: Props) {
|
|||||||
[searchParams, setSearchParams]
|
[searchParams, setSearchParams]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const setGroupBy = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
if (!value || value === 'day') {
|
||||||
|
searchParams.delete('groupBy')
|
||||||
|
} else {
|
||||||
|
searchParams.set('groupBy', value)
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearchParams(searchParams)
|
||||||
|
},
|
||||||
|
[searchParams, setSearchParams]
|
||||||
|
)
|
||||||
|
|
||||||
const filteredData = useMemo(
|
const filteredData = useMemo(
|
||||||
() =>
|
() =>
|
||||||
data?.data?.filter(row => {
|
data?.data
|
||||||
for (const category of selectedCategories) {
|
?.filter(row => {
|
||||||
const value = row[category]
|
for (const category of selectedCategories) {
|
||||||
if (typeof value === 'number' && value > 0) {
|
const value = row[category]
|
||||||
return true
|
if (typeof value === 'number' && value > 0) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
if (showTotal) {
|
||||||
}) ?? [],
|
const value = row['total']
|
||||||
[data?.data, selectedCategories]
|
if (typeof value === 'number' && value > 0) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
?.map(({ date, ...rest }) => ({
|
||||||
|
...rest,
|
||||||
|
date: new Date(date).toLocaleDateString('ru'),
|
||||||
|
rawDate: date,
|
||||||
|
})) ?? [],
|
||||||
|
[data?.data, selectedCategories, showTotal, groupBy]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handlePresetChange = useCallback(
|
const handlePresetChange = useCallback(
|
||||||
@@ -103,6 +152,16 @@ export function VacanciesChart({ data, keywords }: Props) {
|
|||||||
))
|
))
|
||||||
}, [presetsForSelect])
|
}, [presetsForSelect])
|
||||||
|
|
||||||
|
const groupBySelectItems = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.entries(GroupByPeriod).map(([label, value]) => (
|
||||||
|
<SelectItem key={'groupBy-select-item-' + label} value={value}>
|
||||||
|
{capitalize(value)}
|
||||||
|
</SelectItem>
|
||||||
|
)),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={'flex gap-4'}>
|
<div className={'flex gap-4'}>
|
||||||
@@ -121,6 +180,7 @@ export function VacanciesChart({ data, keywords }: Props) {
|
|||||||
{multiSelectItems}
|
{multiSelectItems}
|
||||||
</MultiSelect>
|
</MultiSelect>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-w-xl">
|
<div className="max-w-xl">
|
||||||
<label
|
<label
|
||||||
htmlFor="presets"
|
htmlFor="presets"
|
||||||
@@ -137,9 +197,31 @@ export function VacanciesChart({ data, keywords }: Props) {
|
|||||||
{presetSelectItems}
|
{presetSelectItems}
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="max-w-xl">
|
||||||
|
<label
|
||||||
|
htmlFor="groupBy"
|
||||||
|
className="text-tremor-default text-tremor-content dark:text-dark-tremor-content"
|
||||||
|
>
|
||||||
|
Group by
|
||||||
|
</label>
|
||||||
|
<Select value={groupBy} id={'groupBy'} defaultValue={'day'} onValueChange={setGroupBy}>
|
||||||
|
{groupBySelectItems}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
htmlFor="Show total"
|
||||||
|
className="max-w-xl flex self-center mt-6 gap-2 text-tremor-default text-tremor-content dark:text-dark-tremor-content select-none"
|
||||||
|
>
|
||||||
|
Show total
|
||||||
|
<Switch checked={showTotal} onChange={setShowTotal} id={'Show total'} />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<AreaChart
|
<AreaChart
|
||||||
connectNulls={true}
|
connectNulls={true}
|
||||||
|
customTooltip={args => (
|
||||||
|
<ChartTooltip {...args} groupBy={groupBy} rawDate={args.payload?.payload?.rawDate} />
|
||||||
|
)}
|
||||||
|
enableLegendSlider
|
||||||
className="h-full"
|
className="h-full"
|
||||||
data={filteredData}
|
data={filteredData}
|
||||||
index="date"
|
index="date"
|
||||||
|
|||||||
52
app/lib/tremor/constants.ts
Normal file
52
app/lib/tremor/constants.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { Color, DeltaType, HorizontalPosition, Size, VerticalPosition } from "./inputTypes";
|
||||||
|
|
||||||
|
export const DeltaTypes: { [key: string]: DeltaType } = {
|
||||||
|
Increase: "increase",
|
||||||
|
ModerateIncrease: "moderateIncrease",
|
||||||
|
Decrease: "decrease",
|
||||||
|
ModerateDecrease: "moderateDecrease",
|
||||||
|
Unchanged: "unchanged",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BaseColors: { [key: string]: Color } = {
|
||||||
|
Slate: "slate",
|
||||||
|
Gray: "gray",
|
||||||
|
Zinc: "zinc",
|
||||||
|
Neutral: "neutral",
|
||||||
|
Stone: "stone",
|
||||||
|
Red: "red",
|
||||||
|
Orange: "orange",
|
||||||
|
Amber: "amber",
|
||||||
|
Yellow: "yellow",
|
||||||
|
Lime: "lime",
|
||||||
|
Green: "green",
|
||||||
|
Emerald: "emerald",
|
||||||
|
Teal: "teal",
|
||||||
|
Cyan: "cyan",
|
||||||
|
Sky: "sky",
|
||||||
|
Blue: "blue",
|
||||||
|
Indigo: "indigo",
|
||||||
|
Violet: "violet",
|
||||||
|
Purple: "purple",
|
||||||
|
Fuchsia: "fuchsia",
|
||||||
|
Pink: "pink",
|
||||||
|
Rose: "rose",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Sizes: { [key: string]: Size } = {
|
||||||
|
XS: "xs",
|
||||||
|
SM: "sm",
|
||||||
|
MD: "md",
|
||||||
|
LG: "lg",
|
||||||
|
XL: "xl",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HorizontalPositions: { [key: string]: HorizontalPosition } = {
|
||||||
|
Left: "left",
|
||||||
|
Right: "right",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const VerticalPositions: { [key: string]: VerticalPosition } = {
|
||||||
|
Top: "top",
|
||||||
|
Bottom: "bottom",
|
||||||
|
};
|
||||||
5
app/lib/tremor/index.ts
Normal file
5
app/lib/tremor/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export * from "./constants";
|
||||||
|
export * from "./inputTypes";
|
||||||
|
export * from "./theme";
|
||||||
|
export * from "./tremorTwMerge";
|
||||||
|
export * from "./utils";
|
||||||
72
app/lib/tremor/inputTypes.ts
Normal file
72
app/lib/tremor/inputTypes.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
export type ValueFormatter = {
|
||||||
|
(value: number): string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CurveType = "linear" | "natural" | "monotone" | "step";
|
||||||
|
|
||||||
|
export type Interval = "preserveStartEnd" | "equidistantPreserveStart";
|
||||||
|
|
||||||
|
export type IntervalType = "preserveStartEnd" | Interval;
|
||||||
|
|
||||||
|
const iconVariantValues = ["simple", "light", "shadow", "solid", "outlined"] as const;
|
||||||
|
|
||||||
|
export type IconVariant = (typeof iconVariantValues)[number];
|
||||||
|
|
||||||
|
export type HorizontalPosition = "left" | "right";
|
||||||
|
|
||||||
|
export type VerticalPosition = "top" | "bottom";
|
||||||
|
|
||||||
|
export type ButtonVariant = "primary" | "secondary" | "light";
|
||||||
|
|
||||||
|
const deltaTypeValues = [
|
||||||
|
"increase",
|
||||||
|
"moderateIncrease",
|
||||||
|
"decrease",
|
||||||
|
"moderateDecrease",
|
||||||
|
"unchanged",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type DeltaType = (typeof deltaTypeValues)[number];
|
||||||
|
|
||||||
|
const sizeValues = ["xs", "sm", "md", "lg", "xl"] as const;
|
||||||
|
|
||||||
|
export type Size = (typeof sizeValues)[number];
|
||||||
|
|
||||||
|
const colorValues = [
|
||||||
|
"slate",
|
||||||
|
"gray",
|
||||||
|
"zinc",
|
||||||
|
"neutral",
|
||||||
|
"stone",
|
||||||
|
"red",
|
||||||
|
"orange",
|
||||||
|
"amber",
|
||||||
|
"yellow",
|
||||||
|
"lime",
|
||||||
|
"green",
|
||||||
|
"emerald",
|
||||||
|
"teal",
|
||||||
|
"cyan",
|
||||||
|
"sky",
|
||||||
|
"blue",
|
||||||
|
"indigo",
|
||||||
|
"violet",
|
||||||
|
"purple",
|
||||||
|
"fuchsia",
|
||||||
|
"pink",
|
||||||
|
"rose",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type Color = (typeof colorValues)[number];
|
||||||
|
export type CustomColor = Color | string;
|
||||||
|
export const getIsBaseColor = (color: Color | string) => colorValues.includes(color as Color);
|
||||||
|
|
||||||
|
const justifyContentValues = ["start", "end", "center", "between", "around", "evenly"] as const;
|
||||||
|
export type JustifyContent = (typeof justifyContentValues)[number];
|
||||||
|
|
||||||
|
const alignItemsValues = ["start", "end", "center", "baseline", "stretch"] as const;
|
||||||
|
export type AlignItems = (typeof alignItemsValues)[number];
|
||||||
|
|
||||||
|
export type FlexDirection = "row" | "col" | "row-reverse" | "col-reverse";
|
||||||
|
|
||||||
|
export type FunnelVariantType = "base" | "center";
|
||||||
51
app/lib/tremor/theme.ts
Normal file
51
app/lib/tremor/theme.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { BaseColors } from "./constants";
|
||||||
|
import { Color } from "./inputTypes";
|
||||||
|
|
||||||
|
export const DEFAULT_COLOR: Color = "gray";
|
||||||
|
export const WHITE = "white";
|
||||||
|
export const TRANSPARENT = "transparent";
|
||||||
|
|
||||||
|
export const colorPalette = {
|
||||||
|
canvasBackground: 50,
|
||||||
|
lightBackground: 100,
|
||||||
|
background: 500,
|
||||||
|
darkBackground: 600,
|
||||||
|
darkestBackground: 800,
|
||||||
|
lightBorder: 200,
|
||||||
|
border: 500,
|
||||||
|
darkBorder: 700,
|
||||||
|
lightRing: 200,
|
||||||
|
ring: 300,
|
||||||
|
iconRing: 500,
|
||||||
|
lightText: 400,
|
||||||
|
text: 500,
|
||||||
|
iconText: 600,
|
||||||
|
darkText: 700,
|
||||||
|
darkestText: 900,
|
||||||
|
icon: 500,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const themeColorRange: Color[] = [
|
||||||
|
BaseColors.Blue,
|
||||||
|
BaseColors.Cyan,
|
||||||
|
BaseColors.Sky,
|
||||||
|
BaseColors.Indigo,
|
||||||
|
BaseColors.Violet,
|
||||||
|
BaseColors.Purple,
|
||||||
|
BaseColors.Fuchsia,
|
||||||
|
BaseColors.Slate,
|
||||||
|
BaseColors.Gray,
|
||||||
|
BaseColors.Zinc,
|
||||||
|
BaseColors.Neutral,
|
||||||
|
BaseColors.Stone,
|
||||||
|
BaseColors.Red,
|
||||||
|
BaseColors.Orange,
|
||||||
|
BaseColors.Amber,
|
||||||
|
BaseColors.Yellow,
|
||||||
|
BaseColors.Lime,
|
||||||
|
BaseColors.Green,
|
||||||
|
BaseColors.Emerald,
|
||||||
|
BaseColors.Teal,
|
||||||
|
BaseColors.Pink,
|
||||||
|
BaseColors.Rose,
|
||||||
|
];
|
||||||
36
app/lib/tremor/tremorTwMerge.ts
Normal file
36
app/lib/tremor/tremorTwMerge.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { extendTailwindMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export const tremorTwMerge = extendTailwindMerge({
|
||||||
|
classGroups: {
|
||||||
|
boxShadow: [
|
||||||
|
{
|
||||||
|
shadow: [
|
||||||
|
{
|
||||||
|
tremor: ["input", "card", "dropdown"],
|
||||||
|
"dark-tremor": ["input", "card", "dropdown"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
borderRadius: [
|
||||||
|
{
|
||||||
|
rounded: [
|
||||||
|
{
|
||||||
|
tremor: ["small", "default", "full"],
|
||||||
|
"dark-tremor": ["small", "default", "full"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
fontSize: [
|
||||||
|
{
|
||||||
|
text: [
|
||||||
|
{
|
||||||
|
tremor: ["default", "title", "metric"],
|
||||||
|
"dark-tremor": ["default", "title", "metric"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
114
app/lib/tremor/utils.tsx
Normal file
114
app/lib/tremor/utils.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { DeltaTypes } from "./constants";
|
||||||
|
import { Color, getIsBaseColor, ValueFormatter } from "./inputTypes";
|
||||||
|
|
||||||
|
export const mapInputsToDeltaType = (deltaType: string, isIncreasePositive: boolean): string => {
|
||||||
|
if (isIncreasePositive || deltaType === DeltaTypes.Unchanged) {
|
||||||
|
return deltaType;
|
||||||
|
}
|
||||||
|
switch (deltaType) {
|
||||||
|
case DeltaTypes.Increase:
|
||||||
|
return DeltaTypes.Decrease;
|
||||||
|
case DeltaTypes.ModerateIncrease:
|
||||||
|
return DeltaTypes.ModerateDecrease;
|
||||||
|
case DeltaTypes.Decrease:
|
||||||
|
return DeltaTypes.Increase;
|
||||||
|
case DeltaTypes.ModerateDecrease:
|
||||||
|
return DeltaTypes.ModerateIncrease;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const defaultValueFormatter: ValueFormatter = (value: number) => value.toString();
|
||||||
|
|
||||||
|
export const currencyValueFormatter: ValueFormatter = (e: number) =>
|
||||||
|
`$ ${Intl.NumberFormat("en-US").format(e)}`;
|
||||||
|
|
||||||
|
export const sumNumericArray = (arr: number[]) =>
|
||||||
|
arr.reduce((prefixSum, num) => prefixSum + num, 0);
|
||||||
|
|
||||||
|
export const isValueInArray = (value: any, array: any[]): boolean => {
|
||||||
|
for (let i = 0; i < array.length; i++) {
|
||||||
|
if (array[i] === value) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function mergeRefs<T = any>(
|
||||||
|
refs: Array<React.MutableRefObject<T> | React.LegacyRef<T>>,
|
||||||
|
): React.RefCallback<T> {
|
||||||
|
return (value) => {
|
||||||
|
refs.forEach((ref) => {
|
||||||
|
if (typeof ref === "function") {
|
||||||
|
ref(value);
|
||||||
|
} else if (ref != null) {
|
||||||
|
(ref as React.MutableRefObject<T | null>).current = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeClassName(componentName: string) {
|
||||||
|
return (className: string) => {
|
||||||
|
return `tremor-${componentName}-${className}`;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColorClassNames {
|
||||||
|
bgColor: string;
|
||||||
|
hoverBgColor: string;
|
||||||
|
selectBgColor: string;
|
||||||
|
textColor: string;
|
||||||
|
selectTextColor: string;
|
||||||
|
hoverTextColor: string;
|
||||||
|
borderColor: string;
|
||||||
|
selectBorderColor: string;
|
||||||
|
hoverBorderColor: string;
|
||||||
|
ringColor: string;
|
||||||
|
strokeColor: string;
|
||||||
|
fillColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns boolean based on a determination that a color should be considered an "arbitrary"
|
||||||
|
* Tailwind CSS class.
|
||||||
|
* @see {@link https://tailwindcss.com/docs/background-color#arbitrary-values | Tailwind CSS docs}
|
||||||
|
*/
|
||||||
|
const getIsArbitraryColor = (color: Color | string) =>
|
||||||
|
color.includes("#") || color.includes("--") || color.includes("rgb");
|
||||||
|
|
||||||
|
export function getColorClassNames(color: Color | string, shade?: number): ColorClassNames {
|
||||||
|
const isBaseColor = getIsBaseColor(color);
|
||||||
|
if (color === "white" || color === "black" || color === "transparent" || !shade || !isBaseColor) {
|
||||||
|
const unshadedColor = !getIsArbitraryColor(color) ? color : `[${color}]`;
|
||||||
|
return {
|
||||||
|
bgColor: `bg-${unshadedColor} dark:bg-${unshadedColor}`,
|
||||||
|
hoverBgColor: `hover:bg-${unshadedColor} dark:hover:bg-${unshadedColor}`,
|
||||||
|
selectBgColor: `ui-selected:bg-${unshadedColor} dark:ui-selected:bg-${unshadedColor}`,
|
||||||
|
textColor: `text-${unshadedColor} dark:text-${unshadedColor}`,
|
||||||
|
selectTextColor: `ui-selected:text-${unshadedColor} dark:ui-selected:text-${unshadedColor}`,
|
||||||
|
hoverTextColor: `hover:text-${unshadedColor} dark:hover:text-${unshadedColor}`,
|
||||||
|
borderColor: `border-${unshadedColor} dark:border-${unshadedColor}`,
|
||||||
|
selectBorderColor: `ui-selected:border-${unshadedColor} dark:ui-selected:border-${unshadedColor}`,
|
||||||
|
hoverBorderColor: `hover:border-${unshadedColor} dark:hover:border-${unshadedColor}`,
|
||||||
|
ringColor: `ring-${unshadedColor} dark:ring-${unshadedColor}`,
|
||||||
|
strokeColor: `stroke-${unshadedColor} dark:stroke-${unshadedColor}`,
|
||||||
|
fillColor: `fill-${unshadedColor} dark:fill-${unshadedColor}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
bgColor: `bg-${color}-${shade} dark:bg-${color}-${shade}`,
|
||||||
|
selectBgColor: `ui-selected:bg-${color}-${shade} dark:ui-selected:bg-${color}-${shade}`,
|
||||||
|
hoverBgColor: `hover:bg-${color}-${shade} dark:hover:bg-${color}-${shade}`,
|
||||||
|
textColor: `text-${color}-${shade} dark:text-${color}-${shade}`,
|
||||||
|
selectTextColor: `ui-selected:text-${color}-${shade} dark:ui-selected:text-${color}-${shade}`,
|
||||||
|
hoverTextColor: `hover:text-${color}-${shade} dark:hover:text-${color}-${shade}`,
|
||||||
|
borderColor: `border-${color}-${shade} dark:border-${color}-${shade}`,
|
||||||
|
selectBorderColor: `ui-selected:border-${color}-${shade} dark:ui-selected:border-${color}-${shade}`,
|
||||||
|
hoverBorderColor: `hover:border-${color}-${shade} dark:hover:border-${color}-${shade}`,
|
||||||
|
ringColor: `ring-${color}-${shade} dark:ring-${color}-${shade}`,
|
||||||
|
strokeColor: `stroke-${color}-${shade} dark:stroke-${color}-${shade}`,
|
||||||
|
fillColor: `fill-${color}-${shade} dark:fill-${color}-${shade}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { json, MetaFunction } from '@remix-run/node'
|
import { json, LoaderFunctionArgs, MetaFunction } from '@remix-run/node'
|
||||||
import { vacanciesService } from '~/services/vacancies/vacancies.service'
|
import { vacanciesService } from '~/services/vacancies/vacancies.service'
|
||||||
import { useLoaderData, useSearchParams } from '@remix-run/react'
|
import { useLoaderData, useSearchParams } from '@remix-run/react'
|
||||||
import { VacanciesChart } from '~/components/vacancies-chart'
|
import { VacanciesChart } from '~/components/vacancies-chart'
|
||||||
import type { ShouldRevalidateFunction } from '@remix-run/react'
|
import type { ShouldRevalidateFunction } from '@remix-run/react'
|
||||||
|
import { GroupByPeriod } from '~/services/vacancies/vacancies.types'
|
||||||
|
|
||||||
export const shouldRevalidate: ShouldRevalidateFunction = ({ nextParams }) => {
|
export const shouldRevalidate: ShouldRevalidateFunction = ({ currentUrl, nextUrl }) => {
|
||||||
return !nextParams
|
return currentUrl.searchParams.get('groupBy') !== nextUrl.searchParams.get('groupBy')
|
||||||
}
|
}
|
||||||
export const meta: MetaFunction = ({ location }) => {
|
export const meta: MetaFunction = ({ location }) => {
|
||||||
const preset = new URLSearchParams(location.search).get('preset')
|
const preset = new URLSearchParams(location.search).get('preset')
|
||||||
@@ -16,9 +17,13 @@ export const meta: MetaFunction = ({ location }) => {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const loader = async () => {
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const groupByParam = url.searchParams.get('groupBy')
|
||||||
|
const groupBy = groupByParam ? (groupByParam as GroupByPeriod) : GroupByPeriod.DAY
|
||||||
|
|
||||||
const promises = [
|
const promises = [
|
||||||
vacanciesService.getAggregateByCreatedAt(),
|
vacanciesService.getAggregateByCreatedAt({ groupBy }),
|
||||||
vacanciesService.getKeywords(),
|
vacanciesService.getKeywords(),
|
||||||
] as const
|
] as const
|
||||||
const [vacancies, keywords] = await Promise.all(promises)
|
const [vacancies, keywords] = await Promise.all(promises)
|
||||||
|
|||||||
@@ -1,16 +1,34 @@
|
|||||||
import { KeywordsResponse, Vacancies, VacancyData } from '~/services/vacancies/vacancies.types'
|
import {
|
||||||
|
GroupByPeriod,
|
||||||
|
KeywordsResponse,
|
||||||
|
Vacancies,
|
||||||
|
VacancyData,
|
||||||
|
} from '~/services/vacancies/vacancies.types'
|
||||||
|
|
||||||
export class VacanciesService {
|
export class VacanciesService {
|
||||||
baseUrl = process.env.VACANCIES_API_URL ?? 'https://vacancies-trends-api.andrii.es'
|
baseUrl = process.env.VITE_VACANCIES_API_URL ?? 'https://vacancies-trends-api.andrii.es'
|
||||||
|
|
||||||
async getAll(): Promise<Vacancies> {
|
async getAll(args?: { groupBy?: GroupByPeriod }): Promise<Vacancies> {
|
||||||
return await fetch(`${this.baseUrl}/vacancies`).then(res => res.json())
|
const groupBy = args?.groupBy ?? GroupByPeriod.DAY
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
groupBy,
|
||||||
|
})
|
||||||
|
|
||||||
|
const url = new URL(`${this.baseUrl}/vacancies`)
|
||||||
|
url.search = params.toString()
|
||||||
|
return await fetch(url.toString()).then(res => res.json())
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAggregateByCreatedAt(): Promise<VacancyData> {
|
async getAggregateByCreatedAt(args?: { groupBy?: GroupByPeriod }): Promise<VacancyData> {
|
||||||
return await fetch(`${this.baseUrl}/vacancies/aggregated`)
|
const groupBy = args?.groupBy ?? GroupByPeriod.DAY
|
||||||
.then(res => res.json())
|
|
||||||
.then(this.formatDateOnData)
|
const params = new URLSearchParams({
|
||||||
|
groupBy,
|
||||||
|
})
|
||||||
|
const url = new URL(`${this.baseUrl}/vacancies/aggregated`)
|
||||||
|
url.search = params.toString()
|
||||||
|
|
||||||
|
return await fetch(url).then(res => res.json())
|
||||||
}
|
}
|
||||||
|
|
||||||
async getKeywords(): Promise<KeywordsResponse> {
|
async getKeywords(): Promise<KeywordsResponse> {
|
||||||
|
|||||||
@@ -18,3 +18,10 @@ export type KeywordsResponse = {
|
|||||||
keywords: Record<string, string[]>
|
keywords: Record<string, string[]>
|
||||||
presets: Record<string, string[]>
|
presets: Record<string, string[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum GroupByPeriod {
|
||||||
|
DAY = 'day',
|
||||||
|
WEEK = 'week',
|
||||||
|
MONTH = 'month',
|
||||||
|
YEAR = 'year',
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,10 +20,12 @@
|
|||||||
"@remixicon/react": "^4.2.0",
|
"@remixicon/react": "^4.2.0",
|
||||||
"@tremor/react": "^3.17.2",
|
"@tremor/react": "^3.17.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^3.6.0",
|
||||||
"isbot": "^4.1.0",
|
"isbot": "^4.1.0",
|
||||||
"posthog-js": "^1.148.0",
|
"posthog-js": "^1.148.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"remeda": "^2.11.0",
|
||||||
"tailwind-merge": "^2.3.0"
|
"tailwind-merge": "^2.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
19
pnpm-lock.yaml
generated
19
pnpm-lock.yaml
generated
@@ -35,6 +35,9 @@ importers:
|
|||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
|
date-fns:
|
||||||
|
specifier: ^3.6.0
|
||||||
|
version: 3.6.0
|
||||||
isbot:
|
isbot:
|
||||||
specifier: ^4.1.0
|
specifier: ^4.1.0
|
||||||
version: 4.4.0
|
version: 4.4.0
|
||||||
@@ -47,6 +50,9 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^18.2.0
|
specifier: ^18.2.0
|
||||||
version: 18.3.1(react@18.3.1)
|
version: 18.3.1(react@18.3.1)
|
||||||
|
remeda:
|
||||||
|
specifier: ^2.11.0
|
||||||
|
version: 2.11.0
|
||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^2.3.0
|
specifier: ^2.3.0
|
||||||
version: 2.3.0
|
version: 2.3.0
|
||||||
@@ -3301,6 +3307,9 @@ packages:
|
|||||||
remark-rehype@10.1.0:
|
remark-rehype@10.1.0:
|
||||||
resolution: {integrity: sha512-EFmR5zppdBp0WQeDVZ/b66CWJipB2q2VLNFMabzDSGR66Z2fQii83G5gTBbgGEnEEA0QRussvrFHxk1HWGJskw==}
|
resolution: {integrity: sha512-EFmR5zppdBp0WQeDVZ/b66CWJipB2q2VLNFMabzDSGR66Z2fQii83G5gTBbgGEnEEA0QRussvrFHxk1HWGJskw==}
|
||||||
|
|
||||||
|
remeda@2.11.0:
|
||||||
|
resolution: {integrity: sha512-rQO+zcuNvnTcB2vBctblBARZakY0+wMNtrFGqU1+h4jm5p2APcDKQxUZG2CmMPkSQxa2nauU55GBVS/3Fo83fA==}
|
||||||
|
|
||||||
require-like@0.1.2:
|
require-like@0.1.2:
|
||||||
resolution: {integrity: sha512-oyrU88skkMtDdauHDuKVrgR+zuItqr6/c//FXzvmxRGMexSDc6hNvJInGW3LL46n+8b50RykrvwSUIIQH2LQ5A==}
|
resolution: {integrity: sha512-oyrU88skkMtDdauHDuKVrgR+zuItqr6/c//FXzvmxRGMexSDc6hNvJInGW3LL46n+8b50RykrvwSUIIQH2LQ5A==}
|
||||||
|
|
||||||
@@ -3651,6 +3660,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==}
|
resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
type-fest@4.26.0:
|
||||||
|
resolution: {integrity: sha512-OduNjVJsFbifKb57UqZ2EMP1i4u64Xwow3NYXUtBbD4vIwJdQd4+xl8YDou1dlm4DVrtwT/7Ky8z8WyCULVfxw==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
type-is@1.6.18:
|
type-is@1.6.18:
|
||||||
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
|
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@@ -7566,6 +7579,10 @@ snapshots:
|
|||||||
mdast-util-to-hast: 12.3.0
|
mdast-util-to-hast: 12.3.0
|
||||||
unified: 10.1.2
|
unified: 10.1.2
|
||||||
|
|
||||||
|
remeda@2.11.0:
|
||||||
|
dependencies:
|
||||||
|
type-fest: 4.26.0
|
||||||
|
|
||||||
require-like@0.1.2: {}
|
require-like@0.1.2: {}
|
||||||
|
|
||||||
resolve-from@4.0.0: {}
|
resolve-from@4.0.0: {}
|
||||||
@@ -7983,6 +8000,8 @@ snapshots:
|
|||||||
|
|
||||||
type-fest@0.20.2: {}
|
type-fest@0.20.2: {}
|
||||||
|
|
||||||
|
type-fest@4.26.0: {}
|
||||||
|
|
||||||
type-is@1.6.18:
|
type-is@1.6.18:
|
||||||
dependencies:
|
dependencies:
|
||||||
media-typer: 0.3.0
|
media-typer: 0.3.0
|
||||||
|
|||||||
Reference in New Issue
Block a user