From 39cffa420bdf5b03c03e8568158fb4e7b10e2e11 Mon Sep 17 00:00:00 2001 From: Emilio Hurtado Date: Tue, 15 Apr 2025 22:47:18 -0300 Subject: [PATCH 1/4] feat(leaderboard): implement sorting functionality for driver data Added a sorting dropdown to the leaderboard component, allowing users to sort drivers by various criteria such as position, best lap, last lap, and more. Updated the sorting logic in the sorting utility to accommodate new criteria and integrated a Zustand store for managing the selected sorting criteria. --- dash/src/components/LeaderBoard.tsx | 87 +++++++++++++++++++++-------- dash/src/lib/sorting.ts | 52 +++++++++++++++++ dash/src/stores/useSortingStore.ts | 22 ++++++++ 3 files changed, 138 insertions(+), 23 deletions(-) create mode 100644 dash/src/stores/useSortingStore.ts diff --git a/dash/src/components/LeaderBoard.tsx b/dash/src/components/LeaderBoard.tsx index dc090dea..894f07a9 100644 --- a/dash/src/components/LeaderBoard.tsx +++ b/dash/src/components/LeaderBoard.tsx @@ -3,41 +3,82 @@ import clsx from "clsx"; 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 { objectEntries } from "@/lib/driverHelper"; import Driver from "@/components/driver/Driver"; +const sortOptions: { label: string; value: SortingCriteria }[] = [ + { label: "Position", value: "position" }, + { label: "Best Lap", value: "bestLap" }, + { label: "Last Lap", value: "lastLap" }, + { label: "Pit Status", value: "pitStatus" }, + { label: "Position Change", value: "positionChange" }, + { label: "Sector 1", value: "sector1" }, + { label: "Sector 2", value: "sector2" }, + { label: "Sector 3", value: "sector3" }, + { label: "Tyre Age", value: "tyreAge" }, +]; + 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 setSortCriteria = useSortingStore((state) => state.setCriteria); return ( -
- {showTableHeader && } - - {(!drivers || !driversTiming) && - new Array(20).fill("").map((_, index) => )} - - - {drivers && driversTiming && ( - - {objectEntries(driversTiming.lines) - .sort(sortPos) - .map((timingDriver, index) => ( - - ))} - - )} - +
+
+ + +
+ +
+ {showTableHeader && } + + {(!drivers || !driversTiming) && + new Array(20).fill("").map((_, index) => )} + + + {drivers && driversTiming && ( + + {objectEntries(driversTiming.lines) + .sort((a, b) => + sortDrivers( + sortCriteria, + a, + b, + driversAppTiming?.lines[a.racingNumber], + driversAppTiming?.lines[b.racingNumber] + ) + ) + .map((timingDriver, index) => ( + + ))} + + )} + +
); } diff --git a/dash/src/lib/sorting.ts b/dash/src/lib/sorting.ts index 59e8be33..9ab683d2 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 } from "@/stores/useSortingStore"; type PosObject = { position: string }; export const sortPos = (a: PosObject, b: PosObject) => { @@ -21,3 +23,53 @@ 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, + a: TimingDataDriver, + b: TimingDataDriver, + appDataA?: TimingAppDataDriver, + appDataB?: TimingAppDataDriver +): number => { + switch (criteria) { + case "position": + return parseInt(a.position) - parseInt(b.position); + + case "bestLap": + return lapTimeToMs(a.bestLapTime.value) - lapTimeToMs(b.bestLapTime.value); + + case "lastLap": + return lapTimeToMs(a.lastLapTime.value) - lapTimeToMs(b.lastLapTime.value); + + case "pitStatus": + const getPitPriority = (d: TimingDataDriver) => (d.pitOut ? 0 : d.inPit ? 1 : 2); + return getPitPriority(a) - getPitPriority(b); + + case "positionChange": + const aChange = appDataA ? parseInt(appDataA.gridPos) - parseInt(a.position) : 0; + const bChange = appDataB ? parseInt(appDataB.gridPos) - parseInt(b.position) : 0; + return bChange - aChange; // Sort by most positions gained + + case "sector1": + case "sector2": + case "sector3": + const sectorIndex = parseInt(criteria.slice(-1)) - 1; + return lapTimeToMs(a.sectors[sectorIndex]?.value || "") - lapTimeToMs(b.sectors[sectorIndex]?.value || ""); + + case "tyreAge": + 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; + return bAge - aAge; // Sort by oldest tyres first + + default: + return 0; + } +}; diff --git a/dash/src/stores/useSortingStore.ts b/dash/src/stores/useSortingStore.ts new file mode 100644 index 00000000..c9d3c336 --- /dev/null +++ b/dash/src/stores/useSortingStore.ts @@ -0,0 +1,22 @@ +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 + +interface SortingState { + criteria: SortingCriteria; + setCriteria: (criteria: SortingCriteria) => void; +} + +export const useSortingStore = create((set) => ({ + criteria: "position", + setCriteria: (criteria) => set({ criteria }), +})); \ No newline at end of file From 56f081e0e81ece5e4bbb204b975d3b0bacc83276 Mon Sep 17 00:00:00 2001 From: Emilio Hurtado Date: Wed, 23 Apr 2025 19:03:17 -0300 Subject: [PATCH 2/4] feat(leaderboard): enhance sorting functionality and add react-icons - Added `react-icons` dependency for improved UI elements. - Updated the LeaderBoard component to include sorting icons for better user experience. - Enhanced sorting logic to support direction toggling and integrated it with the Zustand store. - Refactored Select component for better styling and functionality. --- dash/package.json | 1 + dash/src/components/LeaderBoard.tsx | 142 +++++++++++++++++++++------- dash/src/components/Select.tsx | 72 +++++++------- dash/src/lib/sorting.ts | 35 ++++--- dash/src/stores/useSortingStore.ts | 23 +++++ dash/yarn.lock | 10 ++ 6 files changed, 201 insertions(+), 82 deletions(-) diff --git a/dash/package.json b/dash/package.json index b7fc8b56..cef62077 100644 --- a/dash/package.json +++ b/dash/package.json @@ -20,6 +20,7 @@ "pako": "2.1.0", "react": "18.3.1", "react-dom": "18.3.1", + "react-icons": "^5.5.0", "sharp": "0.33.5", "zod": "3.23.8", "zustand": "5.0.1" diff --git a/dash/src/components/LeaderBoard.tsx b/dash/src/components/LeaderBoard.tsx index 894f07a9..3b4a0ae9 100644 --- a/dash/src/components/LeaderBoard.tsx +++ b/dash/src/components/LeaderBoard.tsx @@ -1,5 +1,6 @@ import { AnimatePresence, LayoutGroup } from "framer-motion"; import clsx from "clsx"; +import { BiSortAlt2, BiSortDown, BiSortUp } from "react-icons/bi"; import { useSettingsStore } from "@/stores/useSettingsStore"; import { useDataStore } from "@/stores/useDataStore"; @@ -9,19 +10,29 @@ import { sortDrivers } from "@/lib/sorting"; import { objectEntries } from "@/lib/driverHelper"; import Driver from "@/components/driver/Driver"; - -const sortOptions: { label: string; value: SortingCriteria }[] = [ - { label: "Position", value: "position" }, - { label: "Best Lap", value: "bestLap" }, - { label: "Last Lap", value: "lastLap" }, - { label: "Pit Status", value: "pitStatus" }, - { label: "Position Change", value: "positionChange" }, - { label: "Sector 1", value: "sector1" }, - { label: "Sector 2", value: "sector2" }, - { label: "Sector 3", value: "sector3" }, - { label: "Tyre Age", value: "tyreAge" }, +import Select from "@/components/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); @@ -29,27 +40,48 @@ export default function LeaderBoard() { 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 ( -
-
- - + + Sort + + + {sortDirection === "asc" ? + : + + } + + {showSortOptions && ( +
+ + placeholder="Sort by" + options={sortOptions} + selected={sortCriteria} + setSelected={(value) => value && setSortCriteria(value)} + /> +
+ )}
- {showTableHeader && } + {showTableHeader && } {(!drivers || !driversTiming) && new Array(20).fill("").map((_, index) => )} @@ -61,6 +93,7 @@ export default function LeaderBoard() { .sort((a, b) => sortDrivers( sortCriteria, + sortDirection, a, b, driversAppTiming?.lines[a.racingNumber], @@ -83,7 +116,34 @@ export default function LeaderBoard() { ); } -const TableHeaders = () => { +type TableHeadersProps = { + currentSort: SortingCriteria; + direction: "asc" | "desc"; + onSortChange: (criteria: SortingCriteria) => void; +}; + +const TableHeaders = ({ currentSort, direction, onSortChange }: TableHeadersProps) => { + 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 (
{ gridTemplateColumns: "5.5rem 4rem 5.5rem 4rem 5rem 5.5rem auto auto", }} > -

Position

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

DRS

-

Tire

-

Info

-

Gap

-

LapTime

-

Sectors

+
+ Tire + {renderSortIcon("Tire")} +
+
+ Info + {renderSortIcon("Info")} +
+
+ Gap + {renderSortIcon("Gap")} +
+
+ LapTime + {renderSortIcon("LapTime")} +
+
+ Sectors + {renderSortIcon("Sectors")} +
); }; diff --git a/dash/src/components/Select.tsx b/dash/src/components/Select.tsx index aeee8dd8..0333f84f 100644 --- a/dash/src/components/Select.tsx +++ b/dash/src/components/Select.tsx @@ -1,8 +1,9 @@ "use client"; +import { Fragment, useState } from "react"; import { Combobox, ComboboxButton, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react"; -import { useState } from "react"; import clsx from "clsx"; +import { BiChevronDown } from "react-icons/bi"; type Option = { value: T; @@ -11,54 +12,47 @@ type Option = { type Props = { placeholder?: string; - options: Option[]; - selected: T | null; setSelected: (value: T | null) => void; }; export default function Select({ placeholder, options, selected, setSelected }: Props) { const [query, setQuery] = useState(""); - - const filteredOptions = - query === "" ? options : options.filter((option) => option.label.toLowerCase().includes(query.toLowerCase())); - + const selectedOption = options.find(option => option.value === selected); + 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)} + /> + + +
+ + {options.map((option, index) => ( + + clsx( + "relative cursor-default select-none py-2 pl-3 pr-9", + 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 9ab683d2..ae31d78c 100644 --- a/dash/src/lib/sorting.ts +++ b/dash/src/lib/sorting.ts @@ -1,6 +1,6 @@ import { utc } from "moment"; import type { TimingDataDriver, TimingAppDataDriver } from "@/types/state.type"; -import type { SortingCriteria } from "@/stores/useSortingStore"; +import type { SortingCriteria, SortDirection } from "@/stores/useSortingStore"; type PosObject = { position: string }; export const sortPos = (a: PosObject, b: PosObject) => { @@ -34,42 +34,55 @@ const lapTimeToMs = (time: string): number => { export const sortDrivers = ( criteria: SortingCriteria, + direction: SortDirection, a: TimingDataDriver, b: TimingDataDriver, appDataA?: TimingAppDataDriver, appDataB?: TimingAppDataDriver ): number => { + let result = 0; + switch (criteria) { case "position": - return parseInt(a.position) - parseInt(b.position); + result = parseInt(a.position) - parseInt(b.position); + break; case "bestLap": - return lapTimeToMs(a.bestLapTime.value) - lapTimeToMs(b.bestLapTime.value); + result = lapTimeToMs(a.bestLapTime.value) - lapTimeToMs(b.bestLapTime.value); + break; case "lastLap": - return lapTimeToMs(a.lastLapTime.value) - lapTimeToMs(b.lastLapTime.value); + 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); - return getPitPriority(a) - getPitPriority(b); + 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; - return bChange - aChange; // Sort by most positions gained + result = bChange - aChange; // Sort by most positions gained + break; case "sector1": case "sector2": case "sector3": const sectorIndex = parseInt(criteria.slice(-1)) - 1; - return lapTimeToMs(a.sectors[sectorIndex]?.value || "") - lapTimeToMs(b.sectors[sectorIndex]?.value || ""); + 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; - return bAge - aAge; // Sort by oldest tyres first - - default: - return 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 index c9d3c336..8d101a0e 100644 --- a/dash/src/stores/useSortingStore.ts +++ b/dash/src/stores/useSortingStore.ts @@ -11,12 +11,35 @@ export type SortingCriteria = | "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 1cfef17e..96c262af 100644 --- a/dash/yarn.lock +++ b/dash/yarn.lock @@ -1034,6 +1034,7 @@ __metadata: prettier-plugin-tailwindcss: "npm:0.6.8" react: "npm:18.3.1" react-dom: "npm:18.3.1" + react-icons: "npm:^5.5.0" sharp: "npm:0.33.5" tailwindcss: "npm:3.4.15" typescript: "npm:5.6.3" @@ -2008,6 +2009,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@npm:18.3.1": version: 18.3.1 resolution: "react@npm:18.3.1" From f8b6807fbde5dd9064dd048184c2ef49a10b80e1 Mon Sep 17 00:00:00 2001 From: Slowlydev Date: Thu, 29 May 2025 21:43:07 +0200 Subject: [PATCH 3/4] chore: deps --- dash/package.json | 2 +- dash/yarn.lock | 141 ++++++++++++++++++++++++++++++---------------- 2 files changed, 93 insertions(+), 50 deletions(-) diff --git a/dash/package.json b/dash/package.json index ee5b6929..7e2faddc 100644 --- a/dash/package.json +++ b/dash/package.json @@ -18,9 +18,9 @@ "motion": "12.9.2", "next": "15.3.1", "pako": "2.1.0", - "react-icons": "^5.5.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/yarn.lock b/dash/yarn.lock index d882435d..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 @@ -2210,23 +2269,13 @@ __metadata: motion: "npm:12.9.2" next: "npm:15.3.1" pako: "npm:2.1.0" -<<<<<<< HEAD - postcss: "npm:8.4.49" - prettier: "npm:3.3.3" - prettier-plugin-tailwindcss: "npm:0.6.8" - react: "npm:18.3.1" - react-dom: "npm:18.3.1" - react-icons: "npm:^5.5.0" - sharp: "npm:0.33.5" - tailwindcss: "npm:3.4.15" -======= prettier: "npm:3.5.3" 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" ->>>>>>> 0352dc70248d5c7ab09719af4ab3cb2f71eea622 typescript: "npm:5.6.3" zod: "npm:3.23.8" zustand: "npm:5.0.3" @@ -2254,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 @@ -2281,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 @@ -3244,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: @@ -3778,7 +3827,6 @@ __metadata: languageName: node linkType: hard -<<<<<<< HEAD "react-icons@npm:^5.5.0": version: 5.5.0 resolution: "react-icons@npm:5.5.0" @@ -3788,10 +3836,6 @@ __metadata: languageName: node linkType: hard -"react@npm:18.3.1": - version: 18.3.1 - resolution: "react@npm:18.3.1" -======= "react-is@npm:^16.13.1": version: 16.13.1 resolution: "react-is@npm:16.13.1" @@ -3809,7 +3853,6 @@ __metadata: "reflect.getprototypeof@npm:^1.0.6, reflect.getprototypeof@npm:^1.0.9": version: 1.0.10 resolution: "reflect.getprototypeof@npm:1.0.10" ->>>>>>> 0352dc70248d5c7ab09719af4ab3cb2f71eea622 dependencies: call-bind: "npm:^1.0.8" define-properties: "npm:^1.2.1" @@ -3913,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 From beff59ffbe84ccb941ba239eb82c4c5f97c955dc Mon Sep 17 00:00:00 2001 From: Slowlydev Date: Sat, 21 Jun 2025 14:56:03 +0200 Subject: [PATCH 4/4] perf: updated sse structure less arc mutex --- crates/client/src/consumers.rs | 27 +++---- dash/src/hooks/useDataEngine.ts | 32 ++++---- dash/src/hooks/useDevMode.ts | 9 --- dash/src/hooks/useSocket.ts | 2 +- dash/src/types/message.type.ts | 43 ++++++++++- services/importer/src/main.rs | 2 +- services/live/src/compression.rs | 111 ++++++++++++++-------------- services/live/src/main.rs | 11 +-- services/live/src/server/drivers.rs | 9 +-- services/live/src/server/live.rs | 55 +++++--------- 10 files changed, 150 insertions(+), 151 deletions(-) delete mode 100644 dash/src/hooks/useDevMode.ts diff --git a/crates/client/src/consumers.rs b/crates/client/src/consumers.rs index dbe9a809..4c427cca 100644 --- a/crates/client/src/consumers.rs +++ b/crates/client/src/consumers.rs @@ -1,29 +1,29 @@ -use std::sync::{Arc, Mutex}; +use std::sync::Arc; +use tokio::sync::RwLock; use data::merge::merge; use serde_json::{json, Map, Value}; use tokio::sync::broadcast::{self, Receiver, Sender}; use tokio_stream::{wrappers::ReceiverStream, StreamExt}; -use tracing::error; use crate::message::Message; -pub fn broadcast(mut stream: ReceiverStream) -> (Sender, Receiver) { +pub fn broadcast(stream: ReceiverStream) -> (Sender, Receiver) { let (tx, rx) = broadcast::channel::(32); - - let manage_tx = tx.clone(); + let tx_clone = tx.clone(); tokio::spawn(async move { + tokio::pin!(stream); while let Some(message) = stream.next().await { - let _ = manage_tx.send(message); + let _ = tx_clone.send(message); } }); (tx, rx) } -pub fn keep_state(mut reciver: Receiver) -> Arc> { - let state = Arc::new(Mutex::new(json!({}))); +pub fn keep_state(mut reciver: Receiver) -> Arc> { + let state = Arc::new(RwLock::new(json!({}))); let manage_state = state.clone(); @@ -31,10 +31,7 @@ pub fn keep_state(mut reciver: Receiver) -> Arc> { while let Ok(message) = reciver.recv().await { match message { Message::Updates(updates) => { - let Ok(mut state) = manage_state.lock() else { - error!("failed to lock state"); - continue; - }; + let mut state = manage_state.write().await; for (topic, update) in updates { let mut map = Map::new(); @@ -43,11 +40,7 @@ pub fn keep_state(mut reciver: Receiver) -> Arc> { } } Message::Initial(initial) => { - let Ok(mut state) = manage_state.lock() else { - error!("failed to lock state"); - continue; - }; - + let mut state = manage_state.write().await; *state = initial; } } diff --git a/dash/src/hooks/useDataEngine.ts b/dash/src/hooks/useDataEngine.ts index 6727dc6f..d23b3209 100644 --- a/dash/src/hooks/useDataEngine.ts +++ b/dash/src/hooks/useDataEngine.ts @@ -83,25 +83,25 @@ export const useDataEngine = ({ updateState, updatePosition, updateCarData }: Pr } }; - const handleUpdate = ({ carDataZ, positionZ, ...update }: MessageUpdate) => { - Object.keys(buffers).forEach((key) => { - const data = update[key as keyof typeof update]; - const buffer = buffers[key as keyof typeof buffers]; - if (data) buffer.push(data); - }); - - if (carDataZ) { - const carData = inflate(carDataZ); - for (const entry of carData.Entries) { - carBuffer.pushTimed(entry.Cars, utcToLocalMs(entry.Utc)); + const handleUpdate = (updates: MessageUpdate) => { + for (const [topic, data] of updates) { + if (topic === "carDataZ" && data) { + const carData = inflate(data); + for (const entry of carData.Entries) { + carBuffer.pushTimed(entry.Cars, utcToLocalMs(entry.Utc)); + } + continue; } - } - if (positionZ) { - const position = inflate(positionZ); - for (const entry of position.Position) { - posBuffer.pushTimed(entry.Entries, utcToLocalMs(entry.Timestamp)); + if (topic === "positionZ" && data) { + const position = inflate(data); + for (const entry of position.Position) { + posBuffer.pushTimed(entry.Entries, utcToLocalMs(entry.Timestamp)); + } + continue; } + + buffers[topic as keyof typeof buffers]?.push(data); } }; diff --git a/dash/src/hooks/useDevMode.ts b/dash/src/hooks/useDevMode.ts deleted file mode 100644 index 5cc9a809..00000000 --- a/dash/src/hooks/useDevMode.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const useDevMode = () => { - let active = false; - - if (typeof window != undefined) { - active = !!localStorage.getItem("dev"); - } - - return { active }; -}; diff --git a/dash/src/hooks/useSocket.ts b/dash/src/hooks/useSocket.ts index 20b67f01..fed07ac0 100644 --- a/dash/src/hooks/useSocket.ts +++ b/dash/src/hooks/useSocket.ts @@ -22,7 +22,7 @@ export const useSocket = ({ handleInitial, handleUpdate }: Props) => { handleInitial(JSON.parse(message.data)); }); - sse.addEventListener("update", (message) => { + sse.addEventListener("updates", (message) => { handleUpdate(JSON.parse(message.data)); }); diff --git a/dash/src/types/message.type.ts b/dash/src/types/message.type.ts index f979bc24..9259341c 100644 --- a/dash/src/types/message.type.ts +++ b/dash/src/types/message.type.ts @@ -1,4 +1,22 @@ -import type { State } from "./state.type"; +import type { + ChampionshipPrediction, + DriverList, + ExtrapolatedClock, + Heartbeat, + LapCount, + RaceControlMessages, + SessionData, + SessionInfo, + SessionStatus, + State, + TeamRadio, + TimingAppData, + TimingData, + TimingStats, + TopThree, + TrackStatus, + WeatherData, +} from "./state.type"; export type RecursivePartial = { [P in keyof T]?: T[P] extends (infer U)[] @@ -13,6 +31,27 @@ type FullState = State & { positionZ?: string; }; -export type MessageUpdate = RecursivePartial; +export type UpdateState = { + heartbeat: Heartbeat; + extrapolatedClock: ExtrapolatedClock; + topThree: TopThree; + timingStats: TimingStats; + timingAppData: TimingAppData; + weatherData: WeatherData; + trackStatus: TrackStatus; + sessionStatus: SessionStatus; + driverList: DriverList; + raceControlMessages: RaceControlMessages; + sessionInfo: SessionInfo; + sessionData: SessionData; + lapCount: LapCount; + timingData: TimingData; + teamRadio: TeamRadio; + championshipPrediction: ChampionshipPrediction; + carDataZ: string; + positionZ: string; +}; + +export type MessageUpdate = { [K in keyof UpdateState]: [K, RecursivePartial] }[keyof UpdateState][]; export type MessageInitial = FullState; diff --git a/services/importer/src/main.rs b/services/importer/src/main.rs index be290a2f..6655de5c 100644 --- a/services/importer/src/main.rs +++ b/services/importer/src/main.rs @@ -43,7 +43,7 @@ async fn main() -> Result<(), anyhow::Error> { Message::Updates(updates) => { trace!(?updates, "recived updates, saving"); - let currnet_state = state.lock().unwrap().clone(); + let currnet_state = state.read().await.clone(); let _ = parse_update(&pool, currnet_state, updates).await; } diff --git a/services/live/src/compression.rs b/services/live/src/compression.rs index 336bc7e4..472d62ab 100644 --- a/services/live/src/compression.rs +++ b/services/live/src/compression.rs @@ -9,81 +9,80 @@ use futures_util::stream::Stream; use std::io::Write; use std::pin::{pin, Pin}; -use std::task::Context; -use std::task::Poll; +use std::task::{Context, Poll}; pub async fn compress_sse(request: Request, next: Next) -> Response { - let accept_encoding = request.headers().get(header::ACCEPT_ENCODING).cloned(); + let accept_encoding = request.headers().get(header::ACCEPT_ENCODING).cloned(); - let response = next.run(request).await; + let response = next.run(request).await; - let content_encoding = response.headers().get(header::CONTENT_ENCODING); - let content_type = response.headers().get(header::CONTENT_TYPE); + let content_encoding = response.headers().get(header::CONTENT_ENCODING); + let content_type = response.headers().get(header::CONTENT_TYPE); - // No accept-encoding from client or content-type from server. - let (Some(ct), Some(ae)) = (content_type, accept_encoding) else { - return response; - }; - // Already compressed. - if content_encoding.is_some() { - return response; - } - // Not text/event-stream. - if ct.as_bytes() != b"text/event-stream" { - return response; - } - // Client doesn't accept gzip compression. - if !ae.to_str().map(|v| v.contains("gzip")).unwrap_or(false) { - return response; - } + // No accept-encoding from client or content-type from server. + let (Some(ct), Some(ae)) = (content_type, accept_encoding) else { + return response; + }; + // Already compressed. + if content_encoding.is_some() { + return response; + } + // Not text/event-stream. + if ct.as_bytes() != b"text/event-stream" { + return response; + } + // Client doesn't accept gzip compression. + if !ae.to_str().map(|v| v.contains("gzip")).unwrap_or(false) { + return response; + } - let (mut parts, body) = response.into_parts(); + let (mut parts, body) = response.into_parts(); - let body = body.into_data_stream(); - let body = Body::from_stream(CompressedStream::new(body)); + let body = body.into_data_stream(); + let body = Body::from_stream(CompressedStream::new(body)); - parts.headers.insert( - header::CONTENT_ENCODING, - header::HeaderValue::from_static("gzip"), - ); - parts.headers.insert( - header::VARY, - header::HeaderValue::from_static("accept-encoding"), - ); + parts.headers.insert( + header::CONTENT_ENCODING, + header::HeaderValue::from_static("gzip"), + ); + parts.headers.insert( + header::VARY, + header::HeaderValue::from_static("accept-encoding"), + ); - Response::from_parts(parts, body) + Response::from_parts(parts, body) } struct CompressedStream { - inner: BodyDataStream, - compression: GzEncoder>, + inner: BodyDataStream, + compression: GzEncoder>, } impl CompressedStream { - pub fn new(body: BodyDataStream) -> Self { - Self { - inner: body, - compression: GzEncoder::new(Vec::new(), Compression::default()), - } - } + pub fn new(body: BodyDataStream) -> Self { + Self { + inner: body, + compression: GzEncoder::new(Vec::new(), Compression::default()), + } + } } impl Stream for CompressedStream { - type Item = Result; + type Item = Result; - #[inline] - fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - match pin!(&mut self.inner).as_mut().poll_next(cx) { - Poll::Ready(Some(Ok(x))) => { - self.compression.write_all(&x).unwrap(); - self.compression.flush().unwrap(); + #[inline] + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match pin!(&mut self.inner).as_mut().poll_next(cx) { + Poll::Ready(Some(Ok(x))) => { + self.compression.write_all(&x).unwrap(); + self.compression.flush().unwrap(); - let mut buf = Vec::new(); - std::mem::swap(&mut buf, self.compression.get_mut()); + let mut buf = Vec::new(); + std::mem::swap(&mut buf, self.compression.get_mut()); - Poll::Ready(Some(Ok(buf.into()))) - } - x => x, - } - } + Poll::Ready(Some(Ok(buf.into()))) + } + x => x, + } + } } diff --git a/services/live/src/main.rs b/services/live/src/main.rs index e5684685..8e294a0b 100644 --- a/services/live/src/main.rs +++ b/services/live/src/main.rs @@ -1,8 +1,4 @@ -use std::{ - env, - net::SocketAddr, - sync::{Arc, Mutex}, -}; +use std::{env, net::SocketAddr, sync::Arc}; use axum::{ http::{HeaderValue, Method}, @@ -12,6 +8,7 @@ use axum::{ use compression::compress_sse; use dotenvy::dotenv; use serde_json::Value; +use tokio::sync::RwLock; use tokio::{net::TcpListener, sync::broadcast}; use tower_http::cors::CorsLayer; use tracing::info; @@ -28,7 +25,7 @@ mod server { pub struct AppState { tx: broadcast::Sender, - state: Arc>, + state: Arc>, } #[tokio::main] @@ -70,7 +67,7 @@ async fn main() -> Result<(), anyhow::Error> { } pub fn cors_layer() -> Result { - let origin = env::var("ORIGIN")?; // origins string split by semicolumn + let origin = env::var("ORIGIN")?; let origins = origin .split(';') diff --git a/services/live/src/server/drivers.rs b/services/live/src/server/drivers.rs index faf70f9b..6dc85690 100644 --- a/services/live/src/server/drivers.rs +++ b/services/live/src/server/drivers.rs @@ -1,4 +1,4 @@ -use std::{mem, sync::Arc}; +use std::sync::Arc; use axum::extract::State; use serde_json::Value; @@ -16,11 +16,8 @@ fn map_to_vec(value: Value) -> Vec { pub async fn get_drivers( State(state): State>, ) -> Result>, axum::http::StatusCode> { - let state_lock = state.state.lock().unwrap(); - let live_state = state_lock.clone(); - mem::drop(state_lock); - - match live_state.pointer("/driverList") { + let state_guard = state.state.read().await; + match state_guard.pointer("/driverList") { Some(drivers) => Ok(axum::Json(map_to_vec(drivers.clone()))), None => { error!("failed to get drivers from live state"); diff --git a/services/live/src/server/live.rs b/services/live/src/server/live.rs index 8406eed8..39ef8a99 100644 --- a/services/live/src/server/live.rs +++ b/services/live/src/server/live.rs @@ -1,4 +1,4 @@ -use std::{convert::Infallible, mem, sync::Arc, time::Duration}; +use std::{convert::Infallible, sync::Arc}; use axum::{ extract::State, @@ -6,34 +6,22 @@ use axum::{ }; use client::message::Message; use futures::Stream; -use serde_json::{json, Map, Value}; use tokio_stream::{wrappers::BroadcastStream, StreamExt}; use tracing::{debug, info}; -use data::merge::merge; - use crate::AppState; -// TODO clean this up a bit maybe -fn sse_event(message: Message) -> sse::Event { - let (event, data): (&str, Value) = match message { - Message::Updates(updates) => { - let mut batched_update = json!({}); - - for (topic, update) in updates { - let mut map = Map::new(); - map.insert(topic, update); - merge(&mut batched_update, Value::Object(map)); - } - - // TODO maybe send the updates in array instead of object - - ("update", batched_update) - } - Message::Initial(value) => ("initial", value), - }; - - sse::Event::default().event(event).json_data(data).unwrap() +fn sse_event(message: Message) -> Option { + match message { + Message::Updates(updates) => sse::Event::default() + .event("updates") + .json_data(updates) + .ok(), + Message::Initial(initial) => sse::Event::default() + .event("initial") + .json_data(initial) + .ok(), + } } pub async fn sse_handler( @@ -44,13 +32,14 @@ pub async fn sse_handler( info!(connections, "new sse connection"); - let initial_state_lock = state.state.lock().unwrap(); - let initial_state = initial_state_lock.clone(); - mem::drop(initial_state_lock); + // Use RwLock for concurrent reads and avoid unnecessary clone + let initial_state = { + let state_guard = state.state.read().await; + state_guard.clone() + }; let initial_stream = futures::stream::once(async { debug!("streaming current initial"); - Ok(sse::Event::default() .event("initial") .json_data(initial_state) @@ -59,14 +48,8 @@ pub async fn sse_handler( let updates_stream = BroadcastStream::new(rx) .filter_map(|msg| msg.ok()) - .map(|message| sse_event(message)) + .filter_map(sse_event) .map(Ok); - let stream = initial_stream.chain(updates_stream); - - let keep_alive = sse::KeepAlive::new() - .interval(Duration::from_secs(10)) - .text("keep-alive-text"); - - Sse::new(stream).keep_alive(keep_alive) + Sse::new(initial_stream.chain(updates_stream)) }