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 logo - - - React logo - -
-

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

+
{ + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + > + ( + 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} + +
+ ))} +
{ + e.preventDefault(); + e.stopPropagation(); + createTicketForm.handleSubmit(); + }} + > + ( + field.handleChange(e.target.value)} + /> + )} + /> + + +
+ {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;