diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000..4a95970
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,7 @@
+{
+ "trailingComma": "all",
+ "singleQuote": true,
+ "endOfLine": "lf",
+ "tabWidth": 2,
+ "semi": true
+}
diff --git a/bun.lockb b/bun.lockb
index f728b30..82a62a3 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/eslint.config.js b/eslint.config.js
index 3c68866..aff1e77 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -1,23 +1,27 @@
-import js from '@eslint/js'
-import globals from 'globals'
-import reactHooks from 'eslint-plugin-react-hooks'
-import reactRefresh from 'eslint-plugin-react-refresh'
-import tseslint from 'typescript-eslint'
+import js from '@eslint/js';
+import globals from 'globals';
+import reactHooks from 'eslint-plugin-react-hooks';
+import reactRefresh from 'eslint-plugin-react-refresh';
+import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist'] },
{
- extends: [js.configs.recommended, ...tseslint.configs.recommendedTypeChecked],
+ extends: [
+ js.configs.recommended,
+ ...tseslint.configs.recommendedTypeChecked,
+ ],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
- project: ["./tsconfig.node.json", "./tsconfig.app.json"],
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
plugins: {
+ react: react,
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
@@ -27,6 +31,9 @@ export default tseslint.config(
'warn',
{ allowConstantExport: true },
],
+ 'react/react-in-jsx-scope': 'off',
+ 'no-console': 'warn',
+ curly: ['error', 'all'],
},
},
-)
+);
diff --git a/package.json b/package.json
index 222743d..db26a49 100644
--- a/package.json
+++ b/package.json
@@ -10,6 +10,9 @@
"preview": "vite preview"
},
"dependencies": {
+ "@tanstack/react-form": "^0.33.0",
+ "@tanstack/react-router": "^1.62.0",
+ "appwrite": "^16.0.2",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
@@ -19,6 +22,7 @@
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"eslint": "^9.11.1",
+ "eslint-plugin-react": "^7.37.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.12",
"globals": "^15.9.0",
diff --git a/src/App.tsx b/src/App.tsx
deleted file mode 100644
index afe48ac..0000000
--- a/src/App.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import { useState } from 'react'
-import reactLogo from './assets/react.svg'
-import viteLogo from '/vite.svg'
-import './App.css'
-
-function App() {
- const [count, setCount] = useState(0)
-
- return (
- <>
-
- Vite + React
-
-
-
- Edit src/App.tsx
and save to test HMR
-
-
-
- Click on the Vite and React logos to learn more
-
- >
- )
-}
-
-export default App
diff --git a/src/constants.ts b/src/constants.ts
new file mode 100644
index 0000000..0f1a84b
--- /dev/null
+++ b/src/constants.ts
@@ -0,0 +1,5 @@
+// TODO: Think about moving to .env
+export const APPWRITE_ENDPOINT = 'https://cloud.appwrite.io/v1';
+export const APPWRITE_PROJECT_ID = 'scrummie-poker';
+export const APPWRITE_DATABASE_ID = 'scrummie-poker-db';
+export const APPWRITE_ESTIMATION_SESSION_COLLECTION_ID = 'estimation-session';
diff --git a/src/lib/appwrite.ts b/src/lib/appwrite.ts
new file mode 100644
index 0000000..fa5b8d3
--- /dev/null
+++ b/src/lib/appwrite.ts
@@ -0,0 +1,11 @@
+import { Client, Account, Databases } from 'appwrite';
+import { APPWRITE_ENDPOINT, APPWRITE_PROJECT_ID } from '../constants';
+
+export const client = new Client();
+
+client.setEndpoint(APPWRITE_ENDPOINT).setProject(APPWRITE_PROJECT_ID);
+
+export { ID } from 'appwrite';
+
+export const account = new Account(client);
+export const databases = new Databases(client);
diff --git a/src/lib/context/estimationSession.tsx b/src/lib/context/estimationSession.tsx
new file mode 100644
index 0000000..99f57f1
--- /dev/null
+++ b/src/lib/context/estimationSession.tsx
@@ -0,0 +1,257 @@
+import {
+ createContext,
+ PropsWithChildren,
+ useContext,
+ useEffect,
+ useState,
+} from 'react';
+import { client, databases } from '../appwrite';
+import { ID, Models, Query } from 'appwrite';
+import {
+ APPWRITE_DATABASE_ID,
+ APPWRITE_ESTIMATION_SESSION_COLLECTION_ID,
+} from '../../constants';
+
+interface EstimationSessionType extends Models.Document {
+ UserId: string;
+ Name: string;
+ Tickets: string[];
+ SessionState: string;
+}
+
+interface EstimationSessionTicket {
+ Id: string;
+ Name: string;
+}
+
+interface SessionStateType {
+ CurrentTicketId: string;
+ VotesRevealed: boolean;
+ Votes: {
+ UserId: string;
+ Estimate: number;
+ }[];
+}
+
+interface EstimationSessionsContextType {
+ current: EstimationSessionType[];
+ add: (
+ estimationSession: Omit,
+ ) => Promise;
+ remove: (id: string) => Promise;
+ addTicket: (
+ sessionId: string,
+ ticket: Omit,
+ ) => Promise;
+ getTickets: (sessionId: string) => EstimationSessionTicket[];
+ selectTicket: (sessionId: string, ticketId: string) => Promise;
+ getState: (sessionId: string) => SessionStateType;
+ voteEstimate: (
+ sessionId: string,
+ ticketId: string,
+ estimate: number,
+ userId: string,
+ ) => Promise;
+ revealVotes: (sessionId: string) => Promise;
+}
+
+const EstimationSessionsContext = createContext<
+ EstimationSessionsContextType | undefined
+>(undefined);
+
+export function useEstimationSessions() {
+ return useContext(EstimationSessionsContext);
+}
+
+export function EstimationSessionProvider(props: PropsWithChildren) {
+ const [estimationSessions, setEstimationSessions] = useState<
+ EstimationSessionType[]
+ >([]);
+
+ const add = async (
+ estimationSession: Omit,
+ ) => {
+ const response = await databases.createDocument(
+ APPWRITE_DATABASE_ID,
+ APPWRITE_ESTIMATION_SESSION_COLLECTION_ID,
+ ID.unique(),
+ estimationSession,
+ );
+ setEstimationSessions((estimationSessions) =>
+ [response, ...estimationSessions].slice(0, 10),
+ );
+ };
+
+ const remove = async (id: string) => {
+ await databases.deleteDocument(
+ APPWRITE_DATABASE_ID,
+ APPWRITE_ESTIMATION_SESSION_COLLECTION_ID,
+ id,
+ );
+ setEstimationSessions((estimationSessions) =>
+ estimationSessions.filter(
+ (estimationSession) => estimationSession.$id !== id,
+ ),
+ );
+ await init();
+ };
+
+ const addTicket = async (
+ sessionId: string,
+ ticket: Omit,
+ ) => {
+ const currentSession = estimationSessions.find((x) => x.$id === sessionId);
+ const response = await databases.updateDocument(
+ APPWRITE_DATABASE_ID,
+ APPWRITE_ESTIMATION_SESSION_COLLECTION_ID,
+ sessionId,
+ {
+ Tickets: currentSession?.Tickets.concat([
+ JSON.stringify({
+ ...ticket,
+ Id: crypto.randomUUID(),
+ }),
+ ]),
+ },
+ );
+ setEstimationSessions((estimationSessions) =>
+ estimationSessions
+ .filter((x) => x.$id != sessionId)
+ .concat([response])
+ .slice(0, 10),
+ );
+ };
+
+ const getTickets = (sessionId: string) => {
+ return (
+ estimationSessions
+ .find((x) => x.$id === sessionId)
+ ?.Tickets.map((x) => JSON.parse(x)) ?? []
+ );
+ };
+
+ const selectTicket = async (sessionId: string, ticketId: string) => {
+ const response = await databases.updateDocument(
+ APPWRITE_DATABASE_ID,
+ APPWRITE_ESTIMATION_SESSION_COLLECTION_ID,
+ sessionId,
+ {
+ SessionState: JSON.stringify({
+ CurrentTicketId: ticketId,
+ }),
+ },
+ );
+ setEstimationSessions((estimationSessions) =>
+ estimationSessions
+ .filter((x) => x.$id != sessionId)
+ .concat([response])
+ .slice(0, 10),
+ );
+ };
+
+ const getState = (sessionId: string): SessionStateType => {
+ return JSON.parse(
+ estimationSessions.find((x) => x.$id === sessionId)?.SessionState ?? '{}',
+ );
+ };
+
+ const voteEstimate = async (
+ sessionId: string,
+ ticketId: string,
+ estimate: number,
+ userId: string,
+ ) => {
+ const currentState = getState(sessionId);
+ const newVotes = (currentState.Votes ?? [])
+ .filter((x) => x.UserId !== userId)
+ .concat([
+ {
+ Estimate: estimate,
+ UserId: userId,
+ },
+ ]);
+ const response = await databases.updateDocument(
+ APPWRITE_DATABASE_ID,
+ APPWRITE_ESTIMATION_SESSION_COLLECTION_ID,
+ sessionId,
+ {
+ SessionState: JSON.stringify({
+ CurrentTicketId: ticketId,
+ Votes: newVotes,
+ }),
+ },
+ );
+ setEstimationSessions((estimationSessions) =>
+ estimationSessions
+ .filter((x) => x.$id != sessionId)
+ .concat([response])
+ .slice(0, 10),
+ );
+ };
+
+ const revealVotes = async (sessionId: string) => {
+ const currentState = getState(sessionId);
+ const response = await databases.updateDocument(
+ APPWRITE_DATABASE_ID,
+ APPWRITE_ESTIMATION_SESSION_COLLECTION_ID,
+ sessionId,
+ {
+ SessionState: JSON.stringify({
+ ...currentState,
+ VotesRevealed: true,
+ }),
+ },
+ );
+ setEstimationSessions((estimationSessions) =>
+ estimationSessions
+ .filter((x) => x.$id != sessionId)
+ .concat([response])
+ .slice(0, 10),
+ );
+ };
+
+ const init = async () => {
+ const response = await databases.listDocuments(
+ APPWRITE_DATABASE_ID,
+ APPWRITE_ESTIMATION_SESSION_COLLECTION_ID,
+ [Query.orderDesc('$createdAt'), Query.limit(10)],
+ );
+ setEstimationSessions(response.documents);
+
+ client.subscribe(
+ [
+ `databases.${APPWRITE_DATABASE_ID}.collections.${APPWRITE_ESTIMATION_SESSION_COLLECTION_ID}.documents`,
+ ],
+ (payload) => {
+ setEstimationSessions((estimationSessions) =>
+ estimationSessions
+ .filter((x) => x.$id != payload.payload.$id)
+ .concat([payload.payload])
+ .slice(0, 10),
+ );
+ },
+ );
+ };
+
+ useEffect(() => {
+ init();
+ }, []);
+
+ return (
+
+ {props.children}
+
+ );
+}
diff --git a/src/lib/context/user.tsx b/src/lib/context/user.tsx
new file mode 100644
index 0000000..c30f958
--- /dev/null
+++ b/src/lib/context/user.tsx
@@ -0,0 +1,76 @@
+import { ID, Models } from 'appwrite';
+import {
+ createContext,
+ PropsWithChildren,
+ useContext,
+ useEffect,
+ useState,
+} from 'react';
+import { account } from '../appwrite';
+
+interface UserContextType {
+ current: Models.Session | Models.User | null;
+ login: (email: string, password: string) => Promise;
+ logout: () => Promise;
+ register: (email: string, password: string) => Promise;
+ loginAsGuest: () => Promise;
+}
+
+const UserContext = createContext(undefined);
+
+export const useUser = () => {
+ const context = useContext(UserContext);
+ if (!context) {
+ throw new Error('useUser must be used within a UserProvider');
+ }
+ return context;
+};
+
+export const UserProvider = (props: PropsWithChildren) => {
+ const [user, setUser] = useState<
+ Models.Session | Models.User | null
+ >(null);
+
+ const login = async (email: string, password: string) => {
+ const loggedIn = await account.createEmailPasswordSession(email, password);
+ setUser(loggedIn);
+ window.location.replace('/'); // you can use different redirect method for your application
+ };
+
+ const logout = async () => {
+ await account.deleteSession('current');
+ setUser(null);
+ };
+
+ const register = async (email: string, password: string) => {
+ await account.create(ID.unique(), email, password);
+ await login(email, password);
+ };
+
+ const loginAsGuest = async () => {
+ const session = await account.createAnonymousSession();
+ setUser(session);
+ window.location.replace('/'); // you can use different redirect method for your application
+ };
+
+ const init = async () => {
+ try {
+ const loggedIn = await account.get();
+ setUser(loggedIn);
+ } catch (err) {
+ setUser(null);
+ }
+ };
+
+ useEffect(() => {
+ init();
+ }, []);
+
+ return (
+
+ {props.children}
+
+ );
+};
diff --git a/src/main.tsx b/src/main.tsx
index 6f4ac9b..aa72c55 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,10 +1,67 @@
-import { StrictMode } from 'react'
-import { createRoot } from 'react-dom/client'
-import App from './App.tsx'
-import './index.css'
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import './index.css';
+import {
+ createRootRoute,
+ createRoute,
+ createRouter,
+ RouterProvider,
+} from '@tanstack/react-router';
+import Home from './pages/Home';
+import { UserProvider } from './lib/context/user';
+import Login from './pages/Login';
+import EstimationSession from './pages/EstimationSession';
+import CreateEstimationSession from './pages/CreateEstimationSession';
+import { EstimationSessionProvider } from './lib/context/estimationSession';
+
+const rootRoute = createRootRoute();
+
+const indexRoute = createRoute({
+ path: '/',
+ component: Home,
+ getParentRoute: () => rootRoute,
+});
+
+const loginRoute = createRoute({
+ path: 'login',
+ component: Login,
+ getParentRoute: () => rootRoute,
+});
+
+const createEstimationSessionRoute = createRoute({
+ path: 'estimate/new',
+ component: CreateEstimationSession,
+ getParentRoute: () => rootRoute,
+});
+
+const estimationSessionRoute = createRoute({
+ path: 'estimate/session/$sessionId',
+ component: EstimationSession,
+ getParentRoute: () => rootRoute,
+});
+
+const router = createRouter({
+ routeTree: rootRoute.addChildren([
+ indexRoute,
+ loginRoute,
+ createEstimationSessionRoute,
+ estimationSessionRoute,
+ ]),
+});
+
+declare module '@tanstack/react-router' {
+ interface Register {
+ router: typeof router;
+ }
+}
createRoot(document.getElementById('root')!).render(
-
+
+ {/* TODO: Move ctx providers to layout */}
+
+
+
+
,
-)
+);
diff --git a/src/pages/CreateEstimationSession.tsx b/src/pages/CreateEstimationSession.tsx
new file mode 100644
index 0000000..e19866b
--- /dev/null
+++ b/src/pages/CreateEstimationSession.tsx
@@ -0,0 +1,48 @@
+import { useForm } from '@tanstack/react-form';
+import { useEstimationSessions } from '../lib/context/estimationSession';
+import { useUser } from '../lib/context/user';
+
+const CreateEstimationSession = () => {
+ const user = useUser();
+ const estimationSessions = useEstimationSessions();
+ const form = useForm({
+ defaultValues: {
+ name: '',
+ },
+ onSubmit: async ({ value }) => {
+ await estimationSessions?.add({
+ Name: value.name,
+ UserId: user.current?.$id,
+ });
+ },
+ });
+
+ return (
+ <>
+ Create Estimation Session
+ (
+ field.handleChange(e.target.value)}
+ />
+ )}
+ />
+
+
+ >
+ );
+};
+
+export default CreateEstimationSession;
diff --git a/src/pages/EstimationSession.tsx b/src/pages/EstimationSession.tsx
new file mode 100644
index 0000000..6282520
--- /dev/null
+++ b/src/pages/EstimationSession.tsx
@@ -0,0 +1,110 @@
+import { getRouteApi } from '@tanstack/react-router';
+import { useEstimationSessions } from '../lib/context/estimationSession';
+import { useForm } from '@tanstack/react-form';
+import { useUser } from '../lib/context/user';
+
+const route = getRouteApi('/estimate/session/$sessionId');
+
+const EstimationSession = () => {
+ const { sessionId } = route.useParams();
+ const user = useUser();
+ const estimationSessions = useEstimationSessions();
+ const estimationSession = estimationSessions?.current.find(
+ (x) => x.$id == sessionId,
+ );
+ const tickets = estimationSessions?.getTickets(sessionId);
+ const currentState = estimationSessions?.getState(sessionId);
+
+ const createTicketForm = useForm({
+ defaultValues: {
+ name: '',
+ },
+ onSubmit: async ({ value }) => {
+ await estimationSessions?.addTicket(sessionId, {
+ Name: value.name,
+ });
+ },
+ });
+
+ return (
+ <>
+ Estimation Session - {estimationSession?.Name}
+
+
Tasks
+ {tickets?.map((x) => (
+
+ {x.Id} - {x.Name}
+
+
+ ))}
+
+
+ {currentState?.CurrentTicketId && (
+
+
+ {currentState.CurrentTicketId} -{' '}
+ {tickets?.find((x) => x.Id === currentState.CurrentTicketId)?.Name}
+
+ {[0.5, 1, 2, 3, 5, 8, 13, 21].map((estimate) => (
+
+ ))}
+ {currentState.VotesRevealed ? (
+ <>
+
Votes
+
+ {currentState.Votes.map((vote) => (
+ -
+ {vote.UserId} - {vote.Estimate}
+
+ ))}
+
+ >
+ ) : (
+
+ )}
+
+ )}
+ Session Id: {sessionId}
+ >
+ );
+};
+
+export default EstimationSession;
diff --git a/src/App.css b/src/pages/Home.css
similarity index 89%
rename from src/App.css
rename to src/pages/Home.css
index b9d355d..cf763fc 100644
--- a/src/App.css
+++ b/src/pages/Home.css
@@ -32,11 +32,3 @@
animation: logo-spin infinite 20s linear;
}
}
-
-.card {
- padding: 2em;
-}
-
-.read-the-docs {
- color: #888;
-}
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx
new file mode 100644
index 0000000..38bf87b
--- /dev/null
+++ b/src/pages/Home.tsx
@@ -0,0 +1,46 @@
+import reactLogo from '../assets/react.svg';
+import viteLogo from '/vite.svg';
+import './Home.css';
+import { Link } from '@tanstack/react-router';
+import { useUser } from '../lib/context/user';
+import { useEstimationSessions } from '../lib/context/estimationSession';
+
+function Home() {
+ const user = useUser();
+ const estimationSessions = useEstimationSessions();
+
+ return (
+ <>
+
+ Scrummie-Poker
+
+
+ -
+ Login
+
+ -
+ Create Estimation Session
+
+
+ User Id: {user.current?.$id}
+
+
+
Estimation sessions
+ {estimationSessions?.current.map((session) => (
+
+ {session.Name}
+
+ ))}
+
+ >
+ );
+}
+
+export default Home;
diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx
new file mode 100644
index 0000000..2ca76c5
--- /dev/null
+++ b/src/pages/Login.tsx
@@ -0,0 +1,72 @@
+import { useForm } from '@tanstack/react-form';
+import { useUser } from '../lib/context/user';
+
+const Login = () => {
+ const user = useUser();
+ const form = useForm({
+ defaultValues: {
+ email: '',
+ password: '',
+ },
+ onSubmit: async ({ value }) => {
+ console.log({ value });
+ },
+ });
+
+ return (
+ <>
+ Login or register
+ (
+ field.handleChange(e.target.value)}
+ />
+ )}
+ />
+ (
+ field.handleChange(e.target.value)}
+ />
+ )}
+ />
+
+
+
+
+
+
+ >
+ );
+};
+
+export default Login;