From bfb0949c2551ff1954ed1a918f50b87dc2e28596 Mon Sep 17 00:00:00 2001 From: Andres Date: Thu, 29 Aug 2024 02:32:30 +0200 Subject: [PATCH] add grouping and totals --- app/components/ui/chart-tooltip.tsx | 148 ++++++++++++++++++++ app/components/vacancies-chart.tsx | 106 ++++++++++++-- app/lib/tremor/constants.ts | 52 +++++++ app/lib/tremor/index.ts | 5 + app/lib/tremor/inputTypes.ts | 72 ++++++++++ app/lib/tremor/theme.ts | 51 +++++++ app/lib/tremor/tremorTwMerge.ts | 36 +++++ app/lib/tremor/utils.tsx | 114 +++++++++++++++ app/routes/trends/route.tsx | 15 +- app/services/vacancies/vacancies.service.ts | 34 +++-- app/services/vacancies/vacancies.types.ts | 7 + package.json | 2 + pnpm-lock.yaml | 19 +++ 13 files changed, 636 insertions(+), 25 deletions(-) create mode 100644 app/components/ui/chart-tooltip.tsx create mode 100644 app/lib/tremor/constants.ts create mode 100644 app/lib/tremor/index.ts create mode 100644 app/lib/tremor/inputTypes.ts create mode 100644 app/lib/tremor/theme.ts create mode 100644 app/lib/tremor/tremorTwMerge.ts create mode 100644 app/lib/tremor/utils.tsx diff --git a/app/components/ui/chart-tooltip.tsx b/app/components/ui/chart-tooltip.tsx new file mode 100644 index 0000000..1c52c5e --- /dev/null +++ b/app/components/ui/chart-tooltip.tsx @@ -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 }) => ( +
+ {children} +
+) + +export interface ChartTooltipRowProps { + value: string + name: string + color: Color | string +} + +export const ChartTooltipRow = ({ value, name, color }: ChartTooltipRowProps) => ( +
+
+ +

+ {name} +

+
+

+ {value} +

+
+) + +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 ( + +
+

+ {formatDate(rawDate ?? '', groupBy)} +

+
+ +
+ {filteredPayload.map(({ value, name, color }, idx: number) => ( + + ))} +
+
+ ) + } + 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')}` +} diff --git a/app/components/vacancies-chart.tsx b/app/components/vacancies-chart.tsx index d23e6d0..e1fb4fc 100644 --- a/app/components/vacancies-chart.tsx +++ b/app/components/vacancies-chart.tsx @@ -1,7 +1,9 @@ import { useCallback, useMemo } from 'react' -import { KeywordsResponse, VacancyData } from '~/services/vacancies/vacancies.types' -import { AreaChart, MultiSelect, MultiSelectItem, Select, SelectItem } from '@tremor/react' +import { GroupByPeriod, KeywordsResponse, VacancyData } from '~/services/vacancies/vacancies.types' +import { AreaChart, MultiSelect, MultiSelectItem, Select, SelectItem, Switch } from '@tremor/react' import { useSearchParams } from '@remix-run/react' +import { capitalize } from 'remeda' +import { ChartTooltip } from '~/components/ui/chart-tooltip' type Props = { data?: VacancyData @@ -32,6 +34,11 @@ export function VacanciesChart({ data, keywords }: Props) { return searchParams.get('preset') ?? 'None' }, [searchParams]) + const showTotal = useMemo(() => searchParams.get('showTotal') === 'true', [searchParams]) + const groupBy: GroupByPeriod = useMemo(() => { + return (searchParams.get('groupBy') ?? 'day') as GroupByPeriod + }, [searchParams]) + const setPreset = useCallback( (value: string | null) => { if (!value || value === 'None') { @@ -44,10 +51,27 @@ export function VacanciesChart({ data, keywords }: Props) { }, [searchParams, setSearchParams] ) + const setShowTotal = useCallback( + (value: boolean) => { + if (value) { + searchParams.set('showTotal', 'true') + } else { + searchParams.delete('showTotal') + } + setSearchParams(searchParams) + }, + [searchParams, setSearchParams] + ) const categoriesForChart = useMemo(() => { - return data?.categories.filter(category => selectedCategories.includes(category)) ?? [] - }, [selectedCategories, data?.categories]) + const filtered = [ + ...(data?.categories.filter(category => selectedCategories.includes(category)) ?? []), + ] + if (showTotal) { + filtered.unshift('total') + } + return filtered + }, [selectedCategories, data?.categories, showTotal]) const setSelectedCategories = useCallback( (value: string[]) => { @@ -62,17 +86,42 @@ export function VacanciesChart({ data, keywords }: Props) { [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( () => - data?.data?.filter(row => { - for (const category of selectedCategories) { - const value = row[category] - if (typeof value === 'number' && value > 0) { - return true + data?.data + ?.filter(row => { + for (const category of selectedCategories) { + const value = row[category] + if (typeof value === 'number' && value > 0) { + return true + } } - } - }) ?? [], - [data?.data, selectedCategories] + if (showTotal) { + const value = row['total'] + 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( @@ -103,6 +152,16 @@ export function VacanciesChart({ data, keywords }: Props) { )) }, [presetsForSelect]) + const groupBySelectItems = useMemo( + () => + Object.entries(GroupByPeriod).map(([label, value]) => ( + + {capitalize(value)} + + )), + [] + ) + return ( <>
@@ -121,6 +180,7 @@ export function VacanciesChart({ data, keywords }: Props) { {multiSelectItems}
+
+
+ + +
+ ( + + )} + enableLegendSlider className="h-full" data={filteredData} index="date" diff --git a/app/lib/tremor/constants.ts b/app/lib/tremor/constants.ts new file mode 100644 index 0000000..4853c97 --- /dev/null +++ b/app/lib/tremor/constants.ts @@ -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", +}; diff --git a/app/lib/tremor/index.ts b/app/lib/tremor/index.ts new file mode 100644 index 0000000..4bccf16 --- /dev/null +++ b/app/lib/tremor/index.ts @@ -0,0 +1,5 @@ +export * from "./constants"; +export * from "./inputTypes"; +export * from "./theme"; +export * from "./tremorTwMerge"; +export * from "./utils"; diff --git a/app/lib/tremor/inputTypes.ts b/app/lib/tremor/inputTypes.ts new file mode 100644 index 0000000..26e55ae --- /dev/null +++ b/app/lib/tremor/inputTypes.ts @@ -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"; diff --git a/app/lib/tremor/theme.ts b/app/lib/tremor/theme.ts new file mode 100644 index 0000000..c40a83a --- /dev/null +++ b/app/lib/tremor/theme.ts @@ -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, +]; diff --git a/app/lib/tremor/tremorTwMerge.ts b/app/lib/tremor/tremorTwMerge.ts new file mode 100644 index 0000000..070ae4f --- /dev/null +++ b/app/lib/tremor/tremorTwMerge.ts @@ -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"], + }, + ], + }, + ], + }, +}); diff --git a/app/lib/tremor/utils.tsx b/app/lib/tremor/utils.tsx new file mode 100644 index 0000000..63c5be2 --- /dev/null +++ b/app/lib/tremor/utils.tsx @@ -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( + refs: Array | React.LegacyRef>, +): React.RefCallback { + return (value) => { + refs.forEach((ref) => { + if (typeof ref === "function") { + ref(value); + } else if (ref != null) { + (ref as React.MutableRefObject).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}`, + }; +} diff --git a/app/routes/trends/route.tsx b/app/routes/trends/route.tsx index 8f8e844..0e58c39 100644 --- a/app/routes/trends/route.tsx +++ b/app/routes/trends/route.tsx @@ -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 { useLoaderData, useSearchParams } from '@remix-run/react' import { VacanciesChart } from '~/components/vacancies-chart' import type { ShouldRevalidateFunction } from '@remix-run/react' +import { GroupByPeriod } from '~/services/vacancies/vacancies.types' -export const shouldRevalidate: ShouldRevalidateFunction = ({ nextParams }) => { - return !nextParams +export const shouldRevalidate: ShouldRevalidateFunction = ({ currentUrl, nextUrl }) => { + return currentUrl.searchParams.get('groupBy') !== nextUrl.searchParams.get('groupBy') } export const meta: MetaFunction = ({ location }) => { 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 = [ - vacanciesService.getAggregateByCreatedAt(), + vacanciesService.getAggregateByCreatedAt({ groupBy }), vacanciesService.getKeywords(), ] as const const [vacancies, keywords] = await Promise.all(promises) diff --git a/app/services/vacancies/vacancies.service.ts b/app/services/vacancies/vacancies.service.ts index 8d73825..a9a7bee 100644 --- a/app/services/vacancies/vacancies.service.ts +++ b/app/services/vacancies/vacancies.service.ts @@ -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 { - 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 { - return await fetch(`${this.baseUrl}/vacancies`).then(res => res.json()) + async getAll(args?: { groupBy?: GroupByPeriod }): Promise { + 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 { - return await fetch(`${this.baseUrl}/vacancies/aggregated`) - .then(res => res.json()) - .then(this.formatDateOnData) + async getAggregateByCreatedAt(args?: { groupBy?: GroupByPeriod }): Promise { + const groupBy = args?.groupBy ?? GroupByPeriod.DAY + + 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 { diff --git a/app/services/vacancies/vacancies.types.ts b/app/services/vacancies/vacancies.types.ts index ab2617f..e5a84ff 100644 --- a/app/services/vacancies/vacancies.types.ts +++ b/app/services/vacancies/vacancies.types.ts @@ -18,3 +18,10 @@ export type KeywordsResponse = { keywords: Record presets: Record } + +export enum GroupByPeriod { + DAY = 'day', + WEEK = 'week', + MONTH = 'month', + YEAR = 'year', +} diff --git a/package.json b/package.json index 0b77159..3c0b89a 100644 --- a/package.json +++ b/package.json @@ -20,10 +20,12 @@ "@remixicon/react": "^4.2.0", "@tremor/react": "^3.17.2", "clsx": "^2.1.1", + "date-fns": "^3.6.0", "isbot": "^4.1.0", "posthog-js": "^1.148.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "remeda": "^2.11.0", "tailwind-merge": "^2.3.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e36ca26..765b958 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + date-fns: + specifier: ^3.6.0 + version: 3.6.0 isbot: specifier: ^4.1.0 version: 4.4.0 @@ -47,6 +50,9 @@ importers: react-dom: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + remeda: + specifier: ^2.11.0 + version: 2.11.0 tailwind-merge: specifier: ^2.3.0 version: 2.3.0 @@ -3301,6 +3307,9 @@ packages: remark-rehype@10.1.0: resolution: {integrity: sha512-EFmR5zppdBp0WQeDVZ/b66CWJipB2q2VLNFMabzDSGR66Z2fQii83G5gTBbgGEnEEA0QRussvrFHxk1HWGJskw==} + remeda@2.11.0: + resolution: {integrity: sha512-rQO+zcuNvnTcB2vBctblBARZakY0+wMNtrFGqU1+h4jm5p2APcDKQxUZG2CmMPkSQxa2nauU55GBVS/3Fo83fA==} + require-like@0.1.2: resolution: {integrity: sha512-oyrU88skkMtDdauHDuKVrgR+zuItqr6/c//FXzvmxRGMexSDc6hNvJInGW3LL46n+8b50RykrvwSUIIQH2LQ5A==} @@ -3651,6 +3660,10 @@ packages: resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} engines: {node: '>=10'} + type-fest@4.26.0: + resolution: {integrity: sha512-OduNjVJsFbifKb57UqZ2EMP1i4u64Xwow3NYXUtBbD4vIwJdQd4+xl8YDou1dlm4DVrtwT/7Ky8z8WyCULVfxw==} + engines: {node: '>=16'} + type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} @@ -7566,6 +7579,10 @@ snapshots: mdast-util-to-hast: 12.3.0 unified: 10.1.2 + remeda@2.11.0: + dependencies: + type-fest: 4.26.0 + require-like@0.1.2: {} resolve-from@4.0.0: {} @@ -7983,6 +8000,8 @@ snapshots: type-fest@0.20.2: {} + type-fest@4.26.0: {} + type-is@1.6.18: dependencies: media-typer: 0.3.0