WIP: timer set

This commit is contained in:
2026-07-29 00:11:37 -04:00
parent 2990708174
commit 8f5c499ee2
16 changed files with 539 additions and 110 deletions
+56
View File
@@ -0,0 +1,56 @@
import { DateTime } from "luxon";
import { type FC, useEffect, useState } from "react";
import { useAppDispatch, useAppSelector } from "../store/hooks";
import { clearStartTime, setStartTime } from "../store/slices/timeSlice";
import { ROUND_LENGTH_SECONDS } from "../utils/constants";
import { formatElapsed } from "../utils/functions";
const Welcome: FC = () => {
const dispatch = useAppDispatch();
const startTime = useAppSelector((state) => state.time.startTime);
const [now, setNow] = useState(() => DateTime.now());
useEffect(() => {
if (!startTime) return;
const interval = setInterval(() => setNow(DateTime.now()), 1000);
return () => clearInterval(interval);
}, [startTime]);
const elapsedSeconds = startTime
? now.diff(DateTime.fromISO(startTime), "seconds").seconds
: 0;
const isRunning = startTime !== null;
const isOvertime = elapsedSeconds > 60;
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-8 p-8">
<h1 className="text-4xl font-bold">By Other Means Control Timer</h1>
<div
className={`font-mono text-8xl tabular-nums ${isOvertime ? "text-red-500 animate-bounce" : "text-slate-100"}`}
>
{formatElapsed(elapsedSeconds)}
</div>
{isRunning ? (
<button
type="button"
onClick={() => dispatch(clearStartTime())}
className="rounded-lg bg-slate-700 px-6 py-3 text-lg font-semibold hover:bg-slate-600"
>
Prepare Next Round
</button>
) : (
<button
type="button"
onClick={() => dispatch(setStartTime(DateTime.now().toISO() ?? ""))}
className="rounded-lg bg-emerald-600 px-6 py-3 text-lg font-semibold hover:bg-emerald-500"
>
Start Round
</button>
)}
</div>
);
};
export default Welcome;
+9
View File
@@ -57,3 +57,12 @@ const rootRouteChildren: RootRouteChildren = {
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}
+13 -13
View File
@@ -1,19 +1,19 @@
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
import { createRouter as createTanStackRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
export function getRouter() {
const router = createTanStackRouter({
routeTree,
scrollRestoration: true,
defaultPreload: 'intent',
defaultPreloadStaleTime: 0,
})
const router = createTanStackRouter({
routeTree,
scrollRestoration: true,
defaultPreload: "intent",
defaultPreloadStaleTime: 0,
});
return router
return router;
}
declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof getRouter>
}
declare module "@tanstack/react-router" {
interface Register {
router: ReturnType<typeof getRouter>;
}
}
+57 -48
View File
@@ -1,54 +1,63 @@
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
import { TanStackDevtools } from '@tanstack/react-devtools'
import { TanStackDevtools } from "@tanstack/react-devtools";
import { createRootRoute, HeadContent, Scripts } from "@tanstack/react-router";
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
import { Provider } from "react-redux";
import { PersistGate } from "redux-persist/integration/react";
import appCss from '../styles.css?url'
import { persistor, store } from "#/store/store";
import appCss from "../styles.css?url";
export const Route = createRootRoute({
head: () => ({
meta: [
{
charSet: 'utf-8',
},
{
name: 'viewport',
content: 'width=device-width, initial-scale=1',
},
{
title: 'TanStack Start Starter',
},
],
links: [
{
rel: 'stylesheet',
href: appCss,
},
],
}),
shellComponent: RootDocument,
})
head: () => ({
meta: [
{
charSet: "utf-8",
},
{
name: "viewport",
content: "width=device-width, initial-scale=1",
},
{
title: "By Other Means Control Timer",
},
],
links: [
{
rel: "stylesheet",
href: appCss,
},
],
}),
shellComponent: RootDocument,
});
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
{children}
<TanStackDevtools
config={{
position: 'bottom-right',
}}
plugins={[
{
name: 'Tanstack Router',
render: <TanStackRouterDevtoolsPanel />,
},
]}
/>
<Scripts />
</body>
</html>
)
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body className="bg-slate-900 text-slate-100">
<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
{children}
</PersistGate>
</Provider>
{import.meta.env.VITE_TANSTACK_DEVTOOLS === "true" && (
<TanStackDevtools
config={{
position: "bottom-right",
}}
plugins={[
{
name: "Tanstack Router",
render: <TanStackRouterDevtoolsPanel />,
},
]}
/>
)}
<Scripts />
</body>
</html>
);
}
+7 -11
View File
@@ -1,14 +1,10 @@
import { createFileRoute } from '@tanstack/react-router'
import { createFileRoute } from "@tanstack/react-router";
import Welcome from "../pages/Welcome";
export const Route = createFileRoute('/')({ component: Home })
export const Route = createFileRoute("/")({
component: RouteComponent,
});
function Home() {
return (
<div className="p-8">
<h1 className="text-4xl font-bold">Welcome to TanStack Start</h1>
<p className="mt-4 text-lg">
Edit <code>src/routes/index.tsx</code> to get started.
</p>
</div>
)
function RouteComponent() {
return <Welcome />;
}
+6
View File
@@ -0,0 +1,6 @@
import { useDispatch, useSelector } from "react-redux";
import type { AppDispatch, RootState } from "./store";
// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();
+25
View File
@@ -0,0 +1,25 @@
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
interface StateType {
startTime: string | null;
}
const initialState: StateType = {
startTime: null,
};
const timeSlice = createSlice({
name: "time",
initialState,
reducers: {
setStartTime: (state, action: PayloadAction<string>) => {
state.startTime = action.payload;
},
clearStartTime: (state) => {
state.startTime = null;
},
},
});
export const { setStartTime, clearStartTime } = timeSlice.actions;
export default timeSlice.reducer;
+75
View File
@@ -0,0 +1,75 @@
import { combineReducers, configureStore } from "@reduxjs/toolkit";
import { persistReducer, persistStore } from "redux-persist";
import timeReducer from "./slices/timeSlice";
const noopStorage = {
getItem() {
return Promise.resolve(null);
},
setItem() {
return Promise.resolve();
},
removeItem() {
return Promise.resolve();
},
};
// redux-persist's bundled storage needs `localStorage`, which isn't available
// during server-side rendering (and can throw in locked-down browsers), so
// this is a minimal storage engine that never touches `redux-persist/lib/storage`.
function createLocalStorageEngine() {
if (typeof window === "undefined") return noopStorage;
try {
const testKey = "__by_other_means_storage_test__";
window.localStorage.setItem(testKey, testKey);
window.localStorage.removeItem(testKey);
} catch {
return noopStorage;
}
return {
getItem(key: string) {
return Promise.resolve(window.localStorage.getItem(key));
},
setItem(key: string, value: string) {
window.localStorage.setItem(key, value);
return Promise.resolve();
},
removeItem(key: string) {
window.localStorage.removeItem(key);
return Promise.resolve();
},
};
}
// Configure which slices to persist
const persistConfig = {
key: "root",
storage: createLocalStorageEngine(),
whitelist: ["time"],
};
// Combine all reducers
const rootReducer = combineReducers({
time: timeReducer,
});
// Create persisted reducer
const persistedReducer = persistReducer(persistConfig, rootReducer);
export const store = configureStore({
reducer: persistedReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: ["persist/PERSIST", "persist/REHYDRATE"],
},
}),
});
export const persistor = persistStore(store);
// Infer the `RootState`, `AppDispatch`, and `AppStore` types from the store itself
export type RootState = ReturnType<typeof store.getState>;
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
export type AppDispatch = typeof store.dispatch;
export type AppStore = typeof store;
+2
View File
@@ -0,0 +1,2 @@
const ROUND_LENGTH_MINUTES = 40;
export const ROUND_LENGTH_SECONDS = ROUND_LENGTH_MINUTES * 60;
+6
View File
@@ -0,0 +1,6 @@
export function formatElapsed(seconds: number): string {
const clamped = Math.max(0, Math.floor(seconds));
const minutes = Math.floor(clamped / 60);
const secs = clamped % 60;
return `${minutes}:${secs.toString().padStart(2, "0")}`;
}
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_TANSTACK_DEVTOOLS: string;
}