This commit is contained in:
2026-07-29 04:02:16 -04:00
parent 4a6732798a
commit 9be3f50344
18 changed files with 378 additions and 294 deletions
+2 -1
View File
@@ -18,7 +18,8 @@
}, },
"formatter": { "formatter": {
"enabled": true, "enabled": true,
"indentStyle": "tab" "indentStyle": "space",
"indentWidth": 2
}, },
"assist": { "actions": { "source": { "organizeImports": "on" } } }, "assist": { "actions": { "source": { "organizeImports": "on" } } },
"linter": { "linter": {
+1 -1
View File
@@ -6,7 +6,7 @@
<title>By Other Means Control Timer</title> <title>By Other Means Control Timer</title>
</head> </head>
<body class="bg-slate-900 text-slate-100"> <body class="bg-slate-900 text-slate-100">
<div id="app"></div> <div id="app" class="container mx-auto"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>
+49 -86
View File
@@ -33,6 +33,21 @@
"vite": "^8.0.0" "vite": "^8.0.0"
} }
}, },
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/compat-data": { "node_modules/@babel/compat-data": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
@@ -49,6 +64,7 @@
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.7", "@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7", "@babel/generator": "^7.29.7",
@@ -74,21 +90,6 @@
"url": "https://opencollective.com/babel" "url": "https://opencollective.com/babel"
} }
}, },
"node_modules/@babel/core/node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/generator": { "node_modules/@babel/generator": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
@@ -250,21 +251,6 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/template/node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse": { "node_modules/@babel/traverse": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
@@ -284,21 +270,6 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/traverse/node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/types": { "node_modules/@babel/types": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
@@ -476,6 +447,39 @@
"node": ">=14.21.3" "node": ">=14.21.3"
} }
}, },
"node_modules/@emnapi/core": {
"version": "2.0.0-alpha.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz",
"integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "2.0.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "2.0.0-alpha.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz",
"integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz",
"integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1494,47 +1498,6 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.3", "version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
+1 -1
View File
@@ -6,7 +6,7 @@
"#/*": "./src/*" "#/*": "./src/*"
}, },
"scripts": { "scripts": {
"dev": "vite dev --port 3000", "dev": "vite dev",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"format": "biome format --write .", "format": "biome format --write .",
+49
View File
@@ -0,0 +1,49 @@
import type { FC } from "react";
import { useAppDispatch, useAppSelector } from "../store/hooks";
import {
clearSelectedPhase,
setSelectedPhase,
} from "../store/slices/phaseSlice";
import type { PhaseType } from "../utils/types";
interface PropType {
phase: PhaseType;
current: boolean;
}
const Phase: FC<PropType> = ({ phase, current }) => {
const dispatch = useAppDispatch();
const selected = useAppSelector((state) => state.phase.selected);
const thisSelected = selected === phase.name;
const phaseClick = () => {
if (thisSelected) {
dispatch(clearSelectedPhase());
} else {
dispatch(setSelectedPhase(phase.name));
}
};
return (
<button
type="button"
className={`overflow-hidden relative w-96 p-2 ${phase.color} border border-2 shadow-md rounded-md ${phase.borderColor}`}
onClick={phaseClick}
>
<div className="text-xl px-8">{phase.name}</div>
<div className="text-sm px-8">
{phase.minuteStart}-{phase.minuteEnd}
</div>
{thisSelected && <div className="text-md px-8">{phase.description}</div>}
{current && (
<>
<div className="rotate-45 bg-white h-16 w-16 absolute -top-10 -left-12"></div>
<div className="-rotate-45 bg-white h-16 w-16 absolute -bottom-10 -left-12"></div>
<div className="bg-white w-16 absolute bottom-0 top-0 -left-14"></div>
</>
)}
</button>
);
};
export default Phase;
+26 -14
View File
@@ -1,15 +1,14 @@
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { import { type FC, type MouseEventHandler, useEffect, useState } from "react";
type EventHandler, import Phase from "../components/Phase";
type FC,
type MouseEventHandler,
useEffect,
useState,
} from "react";
import { useAppDispatch, useAppSelector } from "../store/hooks"; import { useAppDispatch, useAppSelector } from "../store/hooks";
import { clearStartTime, setStartTime } from "../store/slices/timeSlice"; import { clearStartTime, setStartTime } from "../store/slices/timeSlice";
import { ROUND_LENGTH_SECONDS } from "../utils/constants"; import { PHASES, ROUND_LENGTH_SECONDS } from "../utils/constants";
import { formatElapsed } from "../utils/functions"; import {
formatElapsed,
getCurrentPhaseIndex,
getElapsedSeconds,
} from "../utils/functions";
const Welcome: FC = () => { const Welcome: FC = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@@ -22,11 +21,15 @@ const Welcome: FC = () => {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [startTime]); }, [startTime]);
const elapsedSeconds = startTime const elapsedSeconds = getElapsedSeconds(startTime, now);
? now.diff(DateTime.fromISO(startTime), "seconds").seconds
: 0;
const isRunning = startTime !== null; const isRunning = startTime !== null;
const isOvertime = elapsedSeconds > 60; const isOvertime = elapsedSeconds > ROUND_LENGTH_SECONDS;
const currentPhaseIndex = getCurrentPhaseIndex(
PHASES,
elapsedSeconds,
isRunning,
);
const nextRoundClick: MouseEventHandler<HTMLButtonElement> = (_e) => { const nextRoundClick: MouseEventHandler<HTMLButtonElement> = (_e) => {
dispatch(clearStartTime()); dispatch(clearStartTime());
@@ -37,7 +40,7 @@ const Welcome: FC = () => {
}; };
return ( return (
<div className="flex min-h-screen flex-col items-center justify-center gap-8 p-8"> <div className="flex min-h-screen flex-col items-center gap-8 p-8">
<h1 className="text-4xl font-bold">By Other Means Control Timer</h1> <h1 className="text-4xl font-bold">By Other Means Control Timer</h1>
<div <div
@@ -63,6 +66,15 @@ const Welcome: FC = () => {
Start Round Start Round
</button> </button>
)} )}
{PHASES.map((phase, index) => {
return (
<Phase
key={phase.name}
phase={phase}
current={index === currentPhaseIndex}
/>
);
})}
</div> </div>
); );
}; };
+25
View File
@@ -0,0 +1,25 @@
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
interface StateType {
selected: string | null;
}
const initialState: StateType = {
selected: null,
};
const phaseSlice = createSlice({
name: "phase",
initialState,
reducers: {
setSelectedPhase: (state, action: PayloadAction<string>) => {
state.selected = action.payload;
},
clearSelectedPhase: (state) => {
state.selected = null;
},
},
});
export const { setSelectedPhase, clearSelectedPhase } = phaseSlice.actions;
export default phaseSlice.reducer;
+5 -46
View File
@@ -1,59 +1,20 @@
import { combineReducers, configureStore } from "@reduxjs/toolkit"; import { combineReducers, configureStore } from "@reduxjs/toolkit";
import { persistReducer, persistStore } from "redux-persist"; import { persistReducer, persistStore } from "redux-persist";
import storage from "redux-persist/es/storage";
import phaseReducer from "./slices/phaseSlice";
import timeReducer from "./slices/timeSlice"; 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 engine can throw or come back malformed in
// some browsers (private mode, locked-down storage), 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 = { const persistConfig = {
key: "root", key: "root",
storage: createLocalStorageEngine(), storage: storage,
whitelist: ["time"], whitelist: ["time", "phase"],
}; };
// Combine all reducers
const rootReducer = combineReducers({ const rootReducer = combineReducers({
time: timeReducer, time: timeReducer,
phase: phaseReducer,
}); });
// Create persisted reducer
const persistedReducer = persistReducer(persistConfig, rootReducer); const persistedReducer = persistReducer(persistConfig, rootReducer);
export const store = configureStore({ export const store = configureStore({
@@ -68,8 +29,6 @@ export const store = configureStore({
export const persistor = persistStore(store); export const persistor = persistStore(store);
// Infer the `RootState`, `AppDispatch`, and `AppStore` types from the store itself
export type RootState = ReturnType<typeof store.getState>; export type RootState = ReturnType<typeof store.getState>;
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
export type AppDispatch = typeof store.dispatch; export type AppDispatch = typeof store.dispatch;
export type AppStore = typeof store; export type AppStore = typeof store;
+40
View File
@@ -1,2 +1,42 @@
import type { PhaseType } from "./types";
const ROUND_LENGTH_MINUTES = 40; const ROUND_LENGTH_MINUTES = 40;
export const ROUND_LENGTH_SECONDS = ROUND_LENGTH_MINUTES * 60; export const ROUND_LENGTH_SECONDS = ROUND_LENGTH_MINUTES * 60;
export const PHASES: PhaseType[] = [
{
minuteStart: 0,
minuteEnd: 15,
name: "Negotiation Phase",
description:
"Attend formal negotiations or Imperial Senate. Draft, formalise and ratify treaties. Apart from the first turn, orders from the previous turn will also be resolved during this phase.",
color: "bg-red-800",
borderColor: "border-red-600",
},
{
minuteStart: 15,
minuteEnd: 20,
name: "Plenary Phase",
description:
"Chairs announce any ratified treaties. Facilitators announce any major map events. Teams collect income.",
color: "bg-yellow-600",
borderColor: "border-yellow-400",
},
{
minuteStart: 20,
minuteEnd: 30,
name: "Discussion Phase",
description: "Freely circulate, discuss any topics as desired.",
color: "bg-green-800",
borderColor: "border-green-600",
},
{
minuteStart: 30,
minuteEnd: 40,
name: "Orders Phase",
description:
"Issue orders, plan for the following turn. Remain at team tables during the Orders phase.",
color: "bg-sky-800",
borderColor: "border-sky-600",
},
];
+24
View File
@@ -1,6 +1,30 @@
import { DateTime } from "luxon";
import type { PhaseType } from "./types";
export function formatElapsed(seconds: number): string { export function formatElapsed(seconds: number): string {
const clamped = Math.max(0, Math.floor(seconds)); const clamped = Math.max(0, Math.floor(seconds));
const minutes = Math.floor(clamped / 60); const minutes = Math.floor(clamped / 60);
const secs = clamped % 60; const secs = clamped % 60;
return `${minutes}:${secs.toString().padStart(2, "0")}`; return `${minutes}:${secs.toString().padStart(2, "0")}`;
} }
export function getElapsedSeconds(
startTime: string | null,
now: DateTime,
): number {
if (!startTime) return 0;
return now.diff(DateTime.fromISO(startTime), "seconds").seconds;
}
export function getCurrentPhaseIndex(
phases: PhaseType[],
elapsedSeconds: number,
isRunning: boolean,
): number {
if (!isRunning) return -1;
const elapsedMinutes = elapsedSeconds / 60;
for (let i = 0; i < phases.length; i++) {
if (elapsedMinutes < phases[i].minuteEnd) return i;
}
return phases.length - 1;
}
+8
View File
@@ -0,0 +1,8 @@
export interface PhaseType {
minuteStart: number;
minuteEnd: number;
name: string;
description: string;
color: string;
borderColor: string;
}
+3
View File
@@ -12,6 +12,9 @@ const config = defineConfig({
tanstackRouter({ target: "react", autoCodeSplitting: true }), tanstackRouter({ target: "react", autoCodeSplitting: true }),
viteReact(), viteReact(),
], ],
server: {
port: 8000,
},
}); });
export default config; export default config;