diff --git a/dash/package.json b/dash/package.json index 0912481d..8a67160b 100644 --- a/dash/package.json +++ b/dash/package.json @@ -20,6 +20,7 @@ "pako": "2.1.0", "react": "19.1.0", "react-dom": "19.1.0", + "react-icons": "^5.5.0", "sharp": "0.34.1", "zod": "3.23.8", "zustand": "5.0.3" diff --git a/dash/src/components/dashboard/LeaderBoard.tsx b/dash/src/components/dashboard/LeaderBoard.tsx index 69f05be9..f68117f3 100644 --- a/dash/src/components/dashboard/LeaderBoard.tsx +++ b/dash/src/components/dashboard/LeaderBoard.tsx @@ -1,49 +1,160 @@ import { AnimatePresence, LayoutGroup } from "motion/react"; import clsx from "clsx"; +import { BiSortAlt2, BiSortDown, BiSortUp } from "react-icons/bi"; import { useSettingsStore } from "@/stores/useSettingsStore"; import { useDataStore } from "@/stores/useDataStore"; +import { useSortingStore, type SortingCriteria } from "@/stores/useSortingStore"; -import { sortPos } from "@/lib/sorting"; +import { sortDrivers } from "@/lib/sorting"; import Driver from "@/components/driver/Driver"; +import Select from "@/components/ui/Select"; + +const sortOptions = [ + { label: "Position", value: "position" as SortingCriteria }, + { label: "Best Lap", value: "bestLap" as SortingCriteria }, + { label: "Last Lap", value: "lastLap" as SortingCriteria }, + { label: "Pit Status", value: "pitStatus" as SortingCriteria }, + { label: "Position Change", value: "positionChange" as SortingCriteria }, + { label: "Sector 1", value: "sector1" as SortingCriteria }, + { label: "Sector 2", value: "sector2" as SortingCriteria }, + { label: "Sector 3", value: "sector3" as SortingCriteria }, + { label: "Tyre Age", value: "tyreAge" as SortingCriteria }, +]; + +const columnSortMapping: Record = { + Position: "position", + Tire: "tyreAge", + Info: "positionChange", + Gap: "position", + LapTime: "bestLap", + Sectors: "sector1", +}; export default function LeaderBoard() { const drivers = useDataStore((state) => state?.driverList); const driversTiming = useDataStore((state) => state?.timingData); + const driversAppTiming = useDataStore((state) => state?.timingAppData); const showTableHeader = useSettingsStore((state) => state.tableHeaders); + const sortCriteria = useSortingStore((state) => state.criteria); + const sortDirection = useSortingStore((state) => state.direction); + const showSortOptions = useSortingStore((state) => state.showSortOptions); + const setSortCriteria = useSortingStore((state) => state.setCriteria); + const toggleDirection = useSortingStore((state) => state.toggleDirection); + const toggleSortOptions = useSortingStore((state) => state.toggleSortOptions); + const setSort = useSortingStore((state) => state.setSort); return ( -
- {showTableHeader && } - - {(!drivers || !driversTiming) && - new Array(20).fill("").map((_, index) => )} - - - {drivers && driversTiming && ( - - {Object.values(driversTiming.lines) - .sort(sortPos) - .map((timingDriver, index) => ( - - ))} - +
+
+ + + {sortDirection === "asc" ? ( + + ) : ( + + )} + + {showSortOptions && ( +
+ + placeholder="Sort by" + options={sortOptions} + selected={sortCriteria} + setSelected={(value) => value && setSortCriteria(value)} + /> +
)} - +
+ +
+ {showTableHeader && ( + + )} + + {(!drivers || !driversTiming) && + new Array(20).fill("").map((_, index) => )} + + + {drivers && driversTiming && ( + + {Object.values(driversTiming.lines) + .sort((a, b) => + sortDrivers( + sortCriteria, + sortDirection, + a, + b, + driversAppTiming?.lines[a.racingNumber], + driversAppTiming?.lines[b.racingNumber], + ), + ) + .map((timingDriver, index) => ( + + ))} + + )} + +
); } -const TableHeaders = () => { +type TableHeadersProps = { + currentSort: SortingCriteria; + direction: "asc" | "desc"; + onSortChange: (criteria: SortingCriteria) => void; +}; + +const TableHeaders = ({ currentSort, direction, onSortChange }: TableHeadersProps) => { const carMetrics = useSettingsStore((state) => state.carMetrics); + const renderSortIcon = (column: string) => { + const criteria = columnSortMapping[column]; + if (!criteria || currentSort !== criteria) return null; + + return direction === "asc" ? ( + + ) : ( + + ); + }; + + const createClickHandler = (column: string) => { + const criteria = columnSortMapping[column]; + if (!criteria) return undefined; + + return () => onSortChange(criteria); + }; + + const headerClass = (column: string) => + clsx( + "cursor-pointer hover:text-zinc-300 transition-colors duration-150 flex items-center", + columnSortMapping[column] && currentSort === columnSortMapping[column] ? "text-sky-400" : "", + ); + return (
{ : "5.5rem 3.5rem 5.5rem 4rem 5rem 5.5rem auto", }} > -

Position

+
+ Position + {renderSortIcon("Position")} +

DRS

-

Tire

-

Info

-

Gap

-

LapTime

-

Sectors

- {carMetrics &&

Car Metrics

} +
+ Tire + {renderSortIcon("Tire")} +
+
+ Info + {renderSortIcon("Info")} +
+
+ Gap + {renderSortIcon("Gap")} +
+
+ LapTime + {renderSortIcon("LapTime")} +
+
+ Sectors + {renderSortIcon("Sectors")} +
); }; diff --git a/dash/src/components/ui/Select.tsx b/dash/src/components/ui/Select.tsx index b52d87e5..928a5439 100644 --- a/dash/src/components/ui/Select.tsx +++ b/dash/src/components/ui/Select.tsx @@ -1,8 +1,9 @@ "use client"; -import { Combobox, ComboboxButton, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react"; import { useState } from "react"; +import { Combobox, ComboboxButton, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react"; import clsx from "clsx"; +import { BiChevronDown } from "react-icons/bi"; type Option = { value: T; @@ -11,9 +12,7 @@ type Option = { type Props = { placeholder?: string; - options: Option[]; - selected: T | null; setSelected: (value: T | null) => void; }; @@ -21,44 +20,43 @@ type Props = { export default function Select({ placeholder, options, selected, setSelected }: Props) { const [query, setQuery] = useState(""); + const selectedOption = options.find((option) => option.value === selected); + const filteredOptions = query === "" ? options : options.filter((option) => option.label.toLowerCase().includes(query.toLowerCase())); return ( - setSelected(value)} onClose={() => setQuery("")}> +
- | null) => option?.label ?? ""} - onChange={(event) => setQuery(event.target.value)} - /> - - {/* */} - +
+ selectedOption?.label || ""} + placeholder={placeholder || "Select option"} + onChange={(event) => setQuery(event.target.value)} + /> + + +
+ + {filteredOptions.map((option, index) => ( + + clsx( + "relative cursor-default py-2 pr-9 pl-3 select-none", + active ? "bg-zinc-700 text-white" : "text-zinc-300", + selected && "bg-zinc-700", + ) + } + > + {option.label} + + ))} +
- - - {filteredOptions.map((option, idx) => ( - - {/* */} -
{option.label}
-
- ))} -
); } diff --git a/dash/src/lib/sorting.ts b/dash/src/lib/sorting.ts index 59e8be33..ae31d78c 100644 --- a/dash/src/lib/sorting.ts +++ b/dash/src/lib/sorting.ts @@ -1,4 +1,6 @@ import { utc } from "moment"; +import type { TimingDataDriver, TimingAppDataDriver } from "@/types/state.type"; +import type { SortingCriteria, SortDirection } from "@/stores/useSortingStore"; type PosObject = { position: string }; export const sortPos = (a: PosObject, b: PosObject) => { @@ -21,3 +23,66 @@ type UtcObject = { utc: string }; export const sortUtc = (a: UtcObject, b: UtcObject) => { return utc(b.utc).diff(utc(a.utc)); }; + +// Convert lap time string (e.g. "1:23.456") to milliseconds for comparison +const lapTimeToMs = (time: string): number => { + if (!time) return Infinity; + const [mins, secs] = time.split(":"); + if (!secs) return parseFloat(mins) * 1000; + return (parseInt(mins) * 60 + parseFloat(secs)) * 1000; +}; + +export const sortDrivers = ( + criteria: SortingCriteria, + direction: SortDirection, + a: TimingDataDriver, + b: TimingDataDriver, + appDataA?: TimingAppDataDriver, + appDataB?: TimingAppDataDriver +): number => { + let result = 0; + + switch (criteria) { + case "position": + result = parseInt(a.position) - parseInt(b.position); + break; + + case "bestLap": + result = lapTimeToMs(a.bestLapTime.value) - lapTimeToMs(b.bestLapTime.value); + break; + + case "lastLap": + result = lapTimeToMs(a.lastLapTime.value) - lapTimeToMs(b.lastLapTime.value); + break; + + case "pitStatus": + // Sort pit status: pit out first, then in pit, then on track + const getPitPriority = (d: TimingDataDriver) => (d.pitOut ? 0 : d.inPit ? 1 : 2); + result = getPitPriority(a) - getPitPriority(b); + break; + + case "positionChange": + // Calculate position changes if grid position data is available + const aChange = appDataA ? parseInt(appDataA.gridPos) - parseInt(a.position) : 0; + const bChange = appDataB ? parseInt(appDataB.gridPos) - parseInt(b.position) : 0; + result = bChange - aChange; // Sort by most positions gained + break; + + case "sector1": + case "sector2": + case "sector3": + const sectorIndex = parseInt(criteria.slice(-1)) - 1; + result = lapTimeToMs(a.sectors[sectorIndex]?.value || "") - lapTimeToMs(b.sectors[sectorIndex]?.value || ""); + break; + + case "tyreAge": + // Sort by tyre age (number of laps on current stint) + const aAge = appDataA?.stints?.length ? appDataA.stints[appDataA.stints.length - 1].totalLaps || 0 : 0; + const bAge = appDataB?.stints?.length ? appDataB.stints[appDataB.stints.length - 1].totalLaps || 0 : 0; + result = bAge - aAge; // Sort by oldest tyres first + break; + } + + // Apply direction + return direction === "asc" ? result : -result; +}; diff --git a/dash/src/stores/useSortingStore.ts b/dash/src/stores/useSortingStore.ts new file mode 100644 index 00000000..8d101a0e --- /dev/null +++ b/dash/src/stores/useSortingStore.ts @@ -0,0 +1,45 @@ +import { create } from "zustand"; + +export type SortingCriteria = + | "position" // Default race position + | "bestLap" // Best lap time + | "lastLap" // Last lap time + | "pitStatus" // In pit/pit out status + | "positionChange" // Positions gained/lost + | "sector1" // Best sector 1 time + | "sector2" // Best sector 2 time + | "sector3" // Best sector 3 time + | "tyreAge"; // Tyre age + +export type SortDirection = "asc" | "desc"; + +interface SortingState { + criteria: SortingCriteria; + direction: SortDirection; + showSortOptions: boolean; + setCriteria: (criteria: SortingCriteria) => void; + toggleDirection: () => void; + toggleSortOptions: () => void; + setSort: (criteria: SortingCriteria) => void; // Set criteria and toggle direction if same criteria +} + +export const useSortingStore = create((set) => ({ + criteria: "position", + direction: "asc", + showSortOptions: false, + setCriteria: (criteria) => set({ criteria }), + toggleDirection: () => set((state) => ({ + direction: state.direction === "asc" ? "desc" : "asc" + })), + toggleSortOptions: () => set((state) => ({ + showSortOptions: !state.showSortOptions + })), + setSort: (criteria) => set((state) => { + // If clicking the same criteria, toggle direction + if (state.criteria === criteria) { + return { direction: state.direction === "asc" ? "desc" : "asc" }; + } + // Otherwise, change criteria and set direction to ascending + return { criteria, direction: "asc" }; + }), +})); \ No newline at end of file diff --git a/dash/yarn.lock b/dash/yarn.lock index 2d818f69..4bdcf182 100644 --- a/dash/yarn.lock +++ b/dash/yarn.lock @@ -127,21 +127,21 @@ __metadata: linkType: hard "@floating-ui/core@npm:^1.6.0": - version: 1.6.9 - resolution: "@floating-ui/core@npm:1.6.9" + version: 1.6.8 + resolution: "@floating-ui/core@npm:1.6.8" dependencies: - "@floating-ui/utils": "npm:^0.2.9" - checksum: 10c0/77debdfc26bc36c6f5ae1f26ab3c15468215738b3f5682af4e1915602fa21ba33ad210273f31c9d2da1c531409929e1afb1138b1608c6b54a0f5853ee84c340d + "@floating-ui/utils": "npm:^0.2.8" + checksum: 10c0/d6985462aeccae7b55a2d3f40571551c8c42bf820ae0a477fc40ef462e33edc4f3f5b7f11b100de77c9b58ecb581670c5c3f46d0af82b5e30aa185c735257eb9 languageName: node linkType: hard "@floating-ui/dom@npm:^1.0.0": - version: 1.6.13 - resolution: "@floating-ui/dom@npm:1.6.13" + version: 1.6.12 + resolution: "@floating-ui/dom@npm:1.6.12" dependencies: "@floating-ui/core": "npm:^1.6.0" - "@floating-ui/utils": "npm:^0.2.9" - checksum: 10c0/272242d2eb6238ffcee0cb1f3c66e0eafae804d5d7b449db5ecf904bc37d31ad96cf575a9e650b93c1190f64f49a684b1559d10e05ed3ec210628b19116991a9 + "@floating-ui/utils": "npm:^0.2.8" + checksum: 10c0/c67b39862175b175c6ac299ea970f17a22c7482cfdf3b1bc79313407bf0880188b022b878953fa69d3ce166ff2bd9ae57c86043e5dd800c262b470d877591b7d languageName: node linkType: hard @@ -171,7 +171,7 @@ __metadata: languageName: node linkType: hard -"@floating-ui/utils@npm:^0.2.8, @floating-ui/utils@npm:^0.2.9": +"@floating-ui/utils@npm:^0.2.8": version: 0.2.9 resolution: "@floating-ui/utils@npm:0.2.9" checksum: 10c0/48bbed10f91cb7863a796cc0d0e917c78d11aeb89f98d03fc38d79e7eb792224a79f538ed8a2d5d5584511d4ca6354ef35f1712659fd569868e342df4398ad6f @@ -607,22 +607,21 @@ __metadata: linkType: hard "@react-aria/focus@npm:^3.17.1": - version: 3.20.2 - resolution: "@react-aria/focus@npm:3.20.2" + version: 3.18.4 + resolution: "@react-aria/focus@npm:3.18.4" dependencies: - "@react-aria/interactions": "npm:^3.25.0" - "@react-aria/utils": "npm:^3.28.2" - "@react-types/shared": "npm:^3.29.0" + "@react-aria/interactions": "npm:^3.22.4" + "@react-aria/utils": "npm:^3.25.3" + "@react-types/shared": "npm:^3.25.0" "@swc/helpers": "npm:^0.5.0" clsx: "npm:^2.0.0" peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/83c7ce227affed990833664b75c99601390ea9c879a44032541447268da22508712c512f5a943f702aef07bfe1e0ea51f554f49db132f17d80b2da9cb71ec687 + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + checksum: 10c0/141f8ef80060c5b58384af4af9446c0792618671e9f963942c3edc29bb15b7eb0ebb62cbe118135c7379c2732e86071aa7d7c890903a0ae411be07f2ec854e6a languageName: node linkType: hard -"@react-aria/interactions@npm:^3.21.3, @react-aria/interactions@npm:^3.25.0": +"@react-aria/interactions@npm:^3.21.3": version: 3.25.0 resolution: "@react-aria/interactions@npm:3.25.0" dependencies: @@ -638,6 +637,31 @@ __metadata: languageName: node linkType: hard +"@react-aria/interactions@npm:^3.22.4": + version: 3.22.4 + resolution: "@react-aria/interactions@npm:3.22.4" + dependencies: + "@react-aria/ssr": "npm:^3.9.6" + "@react-aria/utils": "npm:^3.25.3" + "@react-types/shared": "npm:^3.25.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + checksum: 10c0/8455a68540a4085b71ed034cad5c349a7e756e44cd30d69d340d7f7a66ce1886882021fbcc8049a5d8aeba54b47cd2ca49a7bc4e6910aab2d13b41703d55c7a5 + languageName: node + linkType: hard + +"@react-aria/ssr@npm:^3.9.6": + version: 3.9.6 + resolution: "@react-aria/ssr@npm:3.9.6" + dependencies: + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + checksum: 10c0/be52f2909035e093d3f72cccde15b66b4eef2dc30c71dac46a1ea43d3847dace1a709114640bfa3e9aa72ba716749635fb72116f4da16f7d80248ca348146456 + languageName: node + linkType: hard + "@react-aria/ssr@npm:^3.9.8": version: 3.9.8 resolution: "@react-aria/ssr@npm:3.9.8" @@ -649,6 +673,21 @@ __metadata: languageName: node linkType: hard +"@react-aria/utils@npm:^3.25.3": + version: 3.25.3 + resolution: "@react-aria/utils@npm:3.25.3" + dependencies: + "@react-aria/ssr": "npm:^3.9.6" + "@react-stately/utils": "npm:^3.10.4" + "@react-types/shared": "npm:^3.25.0" + "@swc/helpers": "npm:^0.5.0" + clsx: "npm:^2.0.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + checksum: 10c0/dc86ea48c24232f5c51d0b5317d947c4ccf01a8afb3bdc89cb880a7b0a695a04c8a7c615fb190664f4f3c7da8669ab2bd2f7cdfb2861339f5816cbd600249a84 + languageName: node + linkType: hard + "@react-aria/utils@npm:^3.28.2": version: 3.28.2 resolution: "@react-aria/utils@npm:3.28.2" @@ -675,6 +714,17 @@ __metadata: languageName: node linkType: hard +"@react-stately/utils@npm:^3.10.4": + version: 3.10.4 + resolution: "@react-stately/utils@npm:3.10.4" + dependencies: + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + checksum: 10c0/875c11424fadf4419caceeee13e5bfdee2b0c330fe0220c0ea9d68d570cc9a34525f2f124d977e519b397a738cd2f8e36b7b03a046e3e7da99460e99282977a4 + languageName: node + linkType: hard + "@react-stately/utils@npm:^3.10.6": version: 3.10.6 resolution: "@react-stately/utils@npm:3.10.6" @@ -686,6 +736,15 @@ __metadata: languageName: node linkType: hard +"@react-types/shared@npm:^3.25.0": + version: 3.25.0 + resolution: "@react-types/shared@npm:3.25.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + checksum: 10c0/d168f6b404c345928ef8ead94f0cecd3831d8f6df708dbe897ac62d566949a0931c3b0d95ef6dd02bc5af05b183781b531e6f041ffd1d320bc2cab7697fd27d0 + languageName: node + linkType: hard + "@react-types/shared@npm:^3.29.0": version: 3.29.0 resolution: "@react-types/shared@npm:3.29.0" @@ -1523,9 +1582,9 @@ __metadata: linkType: hard "caniuse-lite@npm:^1.0.30001579": - version: 1.0.30001713 - resolution: "caniuse-lite@npm:1.0.30001713" - checksum: 10c0/f5468abfe73ce30e29cc8bde2ea67df2aab69032bdd93345e0640efefb76b7901c84fe1d28d591a797e65fe52fc24cae97060bb5552f9f9740322aff95ce2f9d + version: 1.0.30001680 + resolution: "caniuse-lite@npm:1.0.30001680" + checksum: 10c0/11a4e7f6f5d5f965cfd4b7dc4aef34e12a26e99647f02b5ac9fd7f7670845473b95ada416a785473237e4b1b67281f7b043c8736c85b77097f6b697e8950b15f languageName: node linkType: hard @@ -2214,6 +2273,7 @@ __metadata: prettier-plugin-tailwindcss: "npm:0.6.11" react: "npm:19.1.0" react-dom: "npm:19.1.0" + react-icons: "npm:^5.5.0" sharp: "npm:0.34.1" tailwindcss: "npm:4.0.8" typescript: "npm:5.6.3" @@ -2243,15 +2303,15 @@ __metadata: linkType: hard "fast-glob@npm:^3.3.2": - version: 3.3.3 - resolution: "fast-glob@npm:3.3.3" + version: 3.3.2 + resolution: "fast-glob@npm:3.3.2" dependencies: "@nodelib/fs.stat": "npm:^2.0.2" "@nodelib/fs.walk": "npm:^1.2.3" glob-parent: "npm:^5.1.2" merge2: "npm:^1.3.0" - micromatch: "npm:^4.0.8" - checksum: 10c0/f6aaa141d0d3384cf73cbcdfc52f475ed293f6d5b65bfc5def368b09163a9f7e5ec2b3014d80f733c405f58e470ee0cc451c2937685045cddcdeaa24199c43fe + micromatch: "npm:^4.0.4" + checksum: 10c0/42baad7b9cd40b63e42039132bde27ca2cb3a4950d0a0f9abe4639ea1aa9d3e3b40f98b1fe31cbc0cc17b664c9ea7447d911a152fa34ec5b72977b125a6fc845 languageName: node linkType: hard @@ -2270,11 +2330,11 @@ __metadata: linkType: hard "fastq@npm:^1.6.0": - version: 1.19.1 - resolution: "fastq@npm:1.19.1" + version: 1.17.1 + resolution: "fastq@npm:1.17.1" dependencies: reusify: "npm:^1.0.4" - checksum: 10c0/ebc6e50ac7048daaeb8e64522a1ea7a26e92b3cee5cd1c7f2316cdca81ba543aa40a136b53891446ea5c3a67ec215fbaca87ad405f102dd97012f62916905630 + checksum: 10c0/1095f16cea45fb3beff558bb3afa74ca7a9250f5a670b65db7ed585f92b4b48381445cd328b3d87323da81e43232b5d5978a8201bde84e0cd514310f1ea6da34 languageName: node linkType: hard @@ -3233,7 +3293,7 @@ __metadata: languageName: node linkType: hard -"micromatch@npm:^4.0.4, micromatch@npm:^4.0.8": +"micromatch@npm:^4.0.4": version: 4.0.8 resolution: "micromatch@npm:4.0.8" dependencies: @@ -3767,6 +3827,15 @@ __metadata: languageName: node linkType: hard +"react-icons@npm:^5.5.0": + version: 5.5.0 + resolution: "react-icons@npm:5.5.0" + peerDependencies: + react: "*" + checksum: 10c0/a24309bfc993c19cbcbfc928157e53a137851822779977b9588f6dd41ffc4d11ebc98b447f4039b0d309a858f0a42980f6bfb4477fb19f9f2d1bc2e190fcf79c + languageName: node + linkType: hard + "react-is@npm:^16.13.1": version: 16.13.1 resolution: "react-is@npm:16.13.1" @@ -3887,9 +3956,9 @@ __metadata: linkType: hard "reusify@npm:^1.0.4": - version: 1.1.0 - resolution: "reusify@npm:1.1.0" - checksum: 10c0/4eff0d4a5f9383566c7d7ec437b671cc51b25963bd61bf127c3f3d3f68e44a026d99b8d2f1ad344afff8d278a8fe70a8ea092650a716d22287e8bef7126bb2fa + version: 1.0.4 + resolution: "reusify@npm:1.0.4" + checksum: 10c0/c19ef26e4e188f408922c46f7ff480d38e8dfc55d448310dfb518736b23ed2c4f547fb64a6ed5bdba92cd7e7ddc889d36ff78f794816d5e71498d645ef476107 languageName: node linkType: hard