diff --git a/README.md b/README.md index 45f0215..193f813 100644 --- a/README.md +++ b/README.md @@ -45,4 +45,4 @@ This will build the app for production to the `dist` directory and start the ser - integrate with Challonge to pull next matches + send final results to Challonge - link to SPARC Judging Guidelines PDF - support SPARC Damage, Control, and Aggression Criteria -- printable version of a judging +- printable version of a judging sheet diff --git a/apps/tanstack-router-app/app.config.js b/apps/tanstack-router-app/app.config.js index 2f30825..fc0a90c 100644 --- a/apps/tanstack-router-app/app.config.js +++ b/apps/tanstack-router-app/app.config.js @@ -9,6 +9,7 @@ export default defineConfig({ vite: { plugins: [ MillionLint.vite({ + enabled: false, rsc: true, optimizeDOM: true, experimental: { diff --git a/apps/tanstack-router-app/app/features/scoring/ScoringSheet.tsx b/apps/tanstack-router-app/app/features/scoring/ScoringSheet.tsx index ae03012..18d4503 100644 --- a/apps/tanstack-router-app/app/features/scoring/ScoringSheet.tsx +++ b/apps/tanstack-router-app/app/features/scoring/ScoringSheet.tsx @@ -1,6 +1,13 @@ +import { + type Observable, + type ObservablePrimitive, + linked, +} from "@legendapp/state"; +import { undoRedo } from "@legendapp/state/helpers/undoRedo"; +import { Reactive, observer, useObserve } from "@legendapp/state/react"; +import { useObservable } from "@legendapp/state/react"; import { Badge, - type BadgeProps, Button, Flex, Grid, @@ -8,48 +15,50 @@ import { RadioGroup, Text, } from "@radix-ui/themes"; -import { type ReactNode, useCallback, useId } from "react"; -import { useMemo, useState } from "react"; -import { memo } from "react"; +import { Redo2, Save, Trash2, Undo2 } from "lucide-react"; +import { type ReactNode, useCallback, useEffect, useId } from "react"; +import { useMemo } from "react"; import Tooltip from "../../components/Tooltip"; +import type { DamageTier, ScoringSheetState } from "../../state/observables"; import { DualColorSlider } from "./DualColorSlider"; -type ScoringSheetProps = { - bot1: string; - bot2: string; - bot1Color: BadgeProps["color"]; - bot2Color: BadgeProps["color"]; -}; +// TODO: why HMR no worky... -type DamageTier = keyof typeof damageTiers; +export const ScoringSheet = observer(function ScoringSheet({ + savedState$, +}: { savedState$: Observable }) { + // TODO: make sure the local state is a copy of the saved state + // TODO: why does any clear and save button cause the whole page to refresh? + const localState$: Observable = useObservable( + savedState$.get(), + ); + const { undo, redo, undos$, redos$ } = undoRedo(localState$, { limit: 100 }); + const engagementScoreArray$ = useObservable( + linked({ + get: () => [localState$.engagementScore.get()], + set: ({ value }) => { + localState$.engagementScore.set(value[0]); + }, + }), + ); + + const unsaved$ = useObservable(() => localState$.get() !== savedState$.get()); + + const bot1Color = localState$.bot1Color.get(); + const bot2Color = localState$.bot2Color.get(); + const engagementScore = localState$.engagementScore.get(); + const bot1DamageTier = localState$.bot1DamageTier.get(); + const bot2DamageTier = localState$.bot2DamageTier.get(); -export function ScoringSheet({ - bot1, - bot2, - bot1Color, - bot2Color, -}: ScoringSheetProps) { const bot2Badge = useMemo( - () => {bot2}, - [bot2, bot2Color], + () => {localState$.bot2.get()}, + [bot2Color, localState$.bot2.get], ); const bot1Badge = useMemo( - () => {bot1}, - [bot1, bot1Color], + () => {localState$.bot1.get()}, + [bot1Color, localState$.bot1.get], ); - const [engagementScoreArray, setEngagementScore] = useState<[number]>([-1]); - const engagementScore = useMemo( - () => engagementScoreArray[0], - [engagementScoreArray], - ); - const [bot1DamageTier, setBot1DamageTier] = useState< - DamageTier | undefined - >(); - const [bot2DamageTier, setBot2DamageTier] = useState< - DamageTier | undefined - >(); - const engagementScoreSummary = useMemo(() => { if (engagementScore === -1) return null; const bot1EngagementScore = 5 + 1 - engagementScore; @@ -134,13 +143,13 @@ export function ScoringSheet({ }, [damageSummary, engagementScoreSummary]); const clearDamageTiers = useCallback(() => { - setBot1DamageTier(undefined); - setBot2DamageTier(undefined); - }, []); + localState$.bot1DamageTier.set(undefined); + localState$.bot2DamageTier.set(undefined); + }, [localState$.bot1DamageTier.set, localState$.bot2DamageTier.set]); const clearEngagementScore = useCallback(() => { - setEngagementScore([-1]); - }, []); + localState$.engagementScore.set(-1); + }, [localState$.engagementScore.set]); const clearAll = useCallback(() => { clearDamageTiers(); @@ -148,17 +157,75 @@ export function ScoringSheet({ }, [clearDamageTiers, clearEngagementScore]); const onEngagementValueChange = useCallback( - (v: [number]) => setEngagementScore(v), - [], + (v: [number]) => { + engagementScoreArray$.set(v); + }, + [engagementScoreArray$.set], ); + const save = useCallback(() => { + console.log("saving"); + savedState$.set(localState$.get()); + }, [localState$.get, savedState$.set]); + + // TODO: undo, redo, save doesn't work + useEffect(() => { + const handleKeyPress = (e: KeyboardEvent) => { + // Check if Ctrl/Cmd key is pressed + if (e.ctrlKey || e.metaKey) { + switch (e.key.toLowerCase()) { + case "z": + if (e.shiftKey) { + e.preventDefault(); + redo(); + } else { + e.preventDefault(); + undo(); + } + break; + case "s": + e.preventDefault(); + save(); + break; + } + } + }; + + window.addEventListener("keydown", handleKeyPress); + return () => window.removeEventListener("keydown", handleKeyPress); + }, [undo, redo, save]); + + useObserve(() => { + console.log("local state changed", localState$.get()); + }); + return (
- - {bot1} vs {bot2} - - + + + + + + + {/* TODO: copy google docs doc title input style */} + + vs + + + Damage {damageSummary && ( @@ -168,17 +235,16 @@ export function ScoringSheet({ )} Engagement @@ -190,6 +256,7 @@ export function ScoringSheet({ )} @@ -200,7 +267,7 @@ export function ScoringSheet({ size="3" min={1} max={6} - value={engagementScoreArray} + value={engagementScoreArray$.get()} onValueChange={onEngagementValueChange} /> {bot2Badge} @@ -222,26 +289,20 @@ export function ScoringSheet({ ); -} +}); type DamageScoringProps = { robotName: string; - damageTierValue: DamageTier | undefined; - setDamageTier: (damageTier: DamageTier) => void; + damageTier: ObservablePrimitive; }; -const DamageScoring = memo(function DamageScoring({ - robotName, - damageTierValue, - setDamageTier, -}: DamageScoringProps) { +function DamageScoring({ robotName, damageTier }: DamageScoringProps) { const onValueChange = useCallback( (e: string) => { - setDamageTier(e as DamageTier); + damageTier.set(e as DamageTier); }, - [setDamageTier], + [damageTier], ); - const id = useId(); const damageTierExplanations: Record = { @@ -285,7 +346,7 @@ const DamageScoring = memo(function DamageScoring({ {Object.entries(damageTierExplanations).map(([dt, explanation]) => { @@ -296,7 +357,7 @@ const DamageScoring = memo(function DamageScoring({ {explanation} @@ -306,15 +367,7 @@ const DamageScoring = memo(function DamageScoring({ ); -}); - -const damageTiers = { - A: 1, - B: 2, - C: 3, - D: 4, - E: 5, -} as const; +} type Scores = [number, number]; diff --git a/apps/tanstack-router-app/app/routeTree.gen.ts b/apps/tanstack-router-app/app/routeTree.gen.ts index 5e68293..d152842 100644 --- a/apps/tanstack-router-app/app/routeTree.gen.ts +++ b/apps/tanstack-router-app/app/routeTree.gen.ts @@ -11,16 +11,30 @@ // Import Routes import { Route as rootRoute } from './routes/__root' +import { Route as AppImport } from './routes/app' import { Route as IndexImport } from './routes/index' +import { Route as AppLocalSheetSheetIdImport } from './routes/app.local-sheet.$sheetId' // Create/Update Routes +const AppRoute = AppImport.update({ + id: '/app', + path: '/app', + getParentRoute: () => rootRoute, +} as any) + const IndexRoute = IndexImport.update({ id: '/', path: '/', getParentRoute: () => rootRoute, } as any) +const AppLocalSheetSheetIdRoute = AppLocalSheetSheetIdImport.update({ + id: '/local-sheet/$sheetId', + path: '/local-sheet/$sheetId', + getParentRoute: () => AppRoute, +} as any) + // Populate the FileRoutesByPath interface declare module '@tanstack/react-router' { @@ -32,39 +46,71 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexImport parentRoute: typeof rootRoute } + '/app': { + id: '/app' + path: '/app' + fullPath: '/app' + preLoaderRoute: typeof AppImport + parentRoute: typeof rootRoute + } + '/app/local-sheet/$sheetId': { + id: '/app/local-sheet/$sheetId' + path: '/local-sheet/$sheetId' + fullPath: '/app/local-sheet/$sheetId' + preLoaderRoute: typeof AppLocalSheetSheetIdImport + parentRoute: typeof AppImport + } } } // Create and export the route tree +interface AppRouteChildren { + AppLocalSheetSheetIdRoute: typeof AppLocalSheetSheetIdRoute +} + +const AppRouteChildren: AppRouteChildren = { + AppLocalSheetSheetIdRoute: AppLocalSheetSheetIdRoute, +} + +const AppRouteWithChildren = AppRoute._addFileChildren(AppRouteChildren) + export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/app': typeof AppRouteWithChildren + '/app/local-sheet/$sheetId': typeof AppLocalSheetSheetIdRoute } export interface FileRoutesByTo { '/': typeof IndexRoute + '/app': typeof AppRouteWithChildren + '/app/local-sheet/$sheetId': typeof AppLocalSheetSheetIdRoute } export interface FileRoutesById { __root__: typeof rootRoute '/': typeof IndexRoute + '/app': typeof AppRouteWithChildren + '/app/local-sheet/$sheetId': typeof AppLocalSheetSheetIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' + fullPaths: '/' | '/app' | '/app/local-sheet/$sheetId' fileRoutesByTo: FileRoutesByTo - to: '/' - id: '__root__' | '/' + to: '/' | '/app' | '/app/local-sheet/$sheetId' + id: '__root__' | '/' | '/app' | '/app/local-sheet/$sheetId' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + AppRoute: typeof AppRouteWithChildren } const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + AppRoute: AppRouteWithChildren, } export const routeTree = rootRoute @@ -77,11 +123,22 @@ export const routeTree = rootRoute "__root__": { "filePath": "__root.tsx", "children": [ - "/" + "/", + "/app" ] }, "/": { "filePath": "index.tsx" + }, + "/app": { + "filePath": "app.tsx", + "children": [ + "/app/local-sheet/$sheetId" + ] + }, + "/app/local-sheet/$sheetId": { + "filePath": "app.local-sheet.$sheetId.tsx", + "parent": "/app" } } } diff --git a/apps/tanstack-router-app/app/routes/__root.tsx b/apps/tanstack-router-app/app/routes/__root.tsx index 2d94d3a..d59c396 100644 --- a/apps/tanstack-router-app/app/routes/__root.tsx +++ b/apps/tanstack-router-app/app/routes/__root.tsx @@ -1,5 +1,5 @@ import { Provider as TooltipProvider } from "@radix-ui/react-tooltip"; -import { Container, Theme } from "@radix-ui/themes"; +import { Theme } from "@radix-ui/themes"; import css from "@radix-ui/themes/styles.css?url"; import { Outlet, @@ -30,11 +30,9 @@ export const Route = createRootRoute({ function RootComponent() { return ( - + - - - + diff --git a/apps/tanstack-router-app/app/routes/app.local-sheet.$sheetId.tsx b/apps/tanstack-router-app/app/routes/app.local-sheet.$sheetId.tsx new file mode 100644 index 0000000..436e913 --- /dev/null +++ b/apps/tanstack-router-app/app/routes/app.local-sheet.$sheetId.tsx @@ -0,0 +1,33 @@ +import { Button, Flex } from "@radix-ui/themes"; +import { Link, createFileRoute, notFound } from "@tanstack/react-router"; +import { X } from "lucide-react"; +import { ScoringSheet } from "../features/scoring/ScoringSheet"; +import { scoringSheets$ } from "../state/observables"; + +export const Route = createFileRoute("/app/local-sheet/$sheetId")({ + component: RouteComponent, +}); + +// TODO: i only want to run this on the client. stop trying to ssr stuff + +function RouteComponent() { + const data = Route.useParams(); + const sheetId = data.sheetId; + const sheet = scoringSheets$.sheets.find( + (sheet) => sheet.id.get() === sheetId, + ); + if (!sheet) { + return
Sheet not found
; + } + return ( + + + + + + + ); +} diff --git a/apps/tanstack-router-app/app/routes/app.tsx b/apps/tanstack-router-app/app/routes/app.tsx new file mode 100644 index 0000000..8c0bdd8 --- /dev/null +++ b/apps/tanstack-router-app/app/routes/app.tsx @@ -0,0 +1,113 @@ +import { + For, + observer +} from "@legendapp/state/react"; +import { Button, Flex, Grid, Heading, Table, Text } from "@radix-ui/themes"; +import { Link, Outlet, createFileRoute } from "@tanstack/react-router"; +import { PlusIcon } from "lucide-react"; +import { decodeTime } from "ulid"; +import { scoringSheets$ } from "../state/observables"; +import TimeAgo from 'react-timeago' + +// TODO: mobile responsiveness (hide the first column on mobile + render a button to open/close it) + +const RouteComponent = observer(function RouteComponent() { + return ( + + + {/* TODO: could be cool to have a randomly selected image from (user-submitted?) matches */} + + + SPARC Judging App + + + {/* TODO: find the robot names */} + Photo: Comet Robotics, Robot 1 vs Robot 2 @ Comet Clash 2024 + + + + + Saved Scoring Sheets + + + + + + + Match + Created + Last Updated + Action + + + + + {(sheet) => { + const updatedAtTs = sheet.updatedAt.get(); + const createdAtTs = decodeTime(sheet.id.get()); + const updatedAt = updatedAtTs ? new Date(updatedAtTs) : null; + const createdAt = new Date(createdAtTs); + // TODO: add an active indicator to the table + return ( + + + {sheet.state.bot1.get()} v {sheet.state.bot2.get()} + + + + {updatedAt ? : "Never"} + + + + + + + + ); + }} + + + + {scoringSheets$.sheets.get().length === 0 && ( + To get started, create a scoring sheet! + )} + + + + + Built by Jason Antwi-Appah and{" "} + + other contributors + + . Source code available on{" "} + GitHub - + currently work in progress :) + + + + + ); +}); + +export const Route = createFileRoute("/app")({ + component: RouteComponent, +}); diff --git a/apps/tanstack-router-app/app/routes/index.tsx b/apps/tanstack-router-app/app/routes/index.tsx index 61e84ca..c0e297a 100644 --- a/apps/tanstack-router-app/app/routes/index.tsx +++ b/apps/tanstack-router-app/app/routes/index.tsx @@ -1,17 +1,10 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { ScoringSheet } from "../features/scoring/ScoringSheet"; +import { createFileRoute, redirect } from "@tanstack/react-router"; export const Route = createFileRoute("/")({ - component: RouteComponent, + // TODO: I'm 90 percent sure there is a way to do this statically + loader() { + throw redirect({ + to: "/app", + }); + }, }); - -function RouteComponent() { - return ( - - ); -} diff --git a/apps/tanstack-router-app/app/state/observables.ts b/apps/tanstack-router-app/app/state/observables.ts new file mode 100644 index 0000000..1327593 --- /dev/null +++ b/apps/tanstack-router-app/app/state/observables.ts @@ -0,0 +1,57 @@ +import { observable } from "@legendapp/state"; +import { ObservablePersistLocalStorage } from "@legendapp/state/persist-plugins/local-storage"; +import { syncObservable } from "@legendapp/state/sync"; +import type { BadgeProps } from "@radix-ui/themes"; +import { ulid } from "ulid"; + +export const damageTiers = { + A: 1, + B: 2, + C: 3, + D: 4, + E: 5, +} as const; + +export type DamageTier = keyof typeof damageTiers; + +export type ScoringSheetState = { + bot1: string; + bot2: string; + bot1Color: BadgeProps["color"]; + bot2Color: BadgeProps["color"]; + engagementScore: number; + bot1DamageTier: DamageTier | undefined; + bot2DamageTier: DamageTier | undefined; +}; + +export const scoringSheets$ = observable({ + sheets: [] as { + id: string; + state: ScoringSheetState; + updatedAt: number | null; + }[], + createSheet: () => { + const id = ulid(); + scoringSheets$.sheets.push({ + id, + state: { + bot1: "Robot A", + bot2: "Robot B", + bot1Color: "ruby", + bot2Color: "indigo", + engagementScore: -1, + bot1DamageTier: undefined, + bot2DamageTier: undefined, + }, + updatedAt: null, + }); + return id; + }, +}); + +syncObservable(scoringSheets$, { + persist: { + name: "persistKey", + plugin: ObservablePersistLocalStorage, + }, +}); diff --git a/apps/tanstack-router-app/app/types.d.ts b/apps/tanstack-router-app/app/types.d.ts new file mode 100644 index 0000000..0bbda1e --- /dev/null +++ b/apps/tanstack-router-app/app/types.d.ts @@ -0,0 +1 @@ +import "@legendapp/state/types/reactive-web"; diff --git a/apps/tanstack-router-app/package.json b/apps/tanstack-router-app/package.json index 7044590..109ea06 100644 --- a/apps/tanstack-router-app/package.json +++ b/apps/tanstack-router-app/package.json @@ -14,8 +14,11 @@ "@radix-ui/themes": "^3.1.6", "@tanstack/react-router": "^1.91.3", "@tanstack/start": "^1.91.3", + "lucide-react": "^0.469.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-timeago": "^7.2.0", + "ulid": "^2.3.0", "vinxi": "^0.5.1" }, "devDependencies": { diff --git a/yarn.lock b/yarn.lock index 31aaff1..7c20eb6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4961,6 +4961,11 @@ lru-cache@^5.1.1: dependencies: yallist "^3.0.2" +lucide-react@^0.469.0: + version "0.469.0" + resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.469.0.tgz#f16936ca6521482fef754a7eabb310e6c68e1482" + integrity sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw== + magic-string@^0.30.12, magic-string@^0.30.14, magic-string@^0.30.17, magic-string@^0.30.3, magic-string@^0.30.8: version "0.30.17" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.17.tgz#450a449673d2460e5bbcfba9a61916a1714c7453" @@ -5786,6 +5791,11 @@ react-style-singleton@^2.2.1, react-style-singleton@^2.2.2: get-nonce "^1.0.0" tslib "^2.0.0" +react-timeago@^7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/react-timeago/-/react-timeago-7.2.0.tgz#ae929d7423a63cbc3dc228e49d22fbf586d459ca" + integrity sha512-2KsBEEs+qRhKx/kekUVNSTIpop3Jwd7SRBm0R4Eiq3mPeswRGSsftY9FpKsE/lXLdURyQFiHeHFrIUxLYskG5g== + react@^18.3.1: version "18.3.1" resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891" @@ -6736,6 +6746,11 @@ ufo@^1.3.0, ufo@^1.3.2, ufo@^1.5.4: resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.5.4.tgz#16d6949674ca0c9e0fbbae1fa20a71d7b1ded754" integrity sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ== +ulid@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/ulid/-/ulid-2.3.0.tgz#93063522771a9774121a84d126ecd3eb9804071f" + integrity sha512-keqHubrlpvT6G2wH0OEfSW4mquYRcbe/J8NMmveoQOjUqmo+hXtO+ORCpWhdbZ7k72UtY61BL7haGxW6enBnjw== + unbox-primitive@^1.0.2: version "1.1.0" resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.1.0.tgz#8d9d2c9edeea8460c7f35033a88867944934d1e2"