diff --git a/src/components/Button.tsx b/src/components/Button.tsx index fa2e82e..0d3b5a9 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -9,15 +9,17 @@ enum ButtonColor { interface ButtonProps extends React.ButtonHTMLAttributes { color?: ButtonColor; + fullWidth?: boolean; } const Button: React.FC = ({ children, color = ButtonColor.Primary, + fullWidth = false, ...props }) => { const buttonClass = classNames( - 'flex w-full justify-center rounded-md px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2', + 'flex justify-center rounded-md px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2', { 'bg-indigo-600 hover:bg-indigo-500 focus-visible:outline-indigo-600': color === ButtonColor.Primary, @@ -27,6 +29,7 @@ const Button: React.FC = ({ color === ButtonColor.Error, 'bg-green-600 hover:bg-green-500 focus-visible:outline-green-600': color === ButtonColor.Success, + 'w-full': fullWidth, }, ); diff --git a/src/components/Card.tsx b/src/components/Card.tsx index 55cd5b4..37aa0c3 100644 --- a/src/components/Card.tsx +++ b/src/components/Card.tsx @@ -11,7 +11,6 @@ const Card: React.FC = ({ title, description, onClick }) => { 'p-4 border rounded-lg shadow-sm transition', { 'hover:bg-gray-100 dark:hover:bg-nero-800 cursor-pointer': onClick, - 'cursor-default': !onClick, }, 'border-gray-300 dark:border-nero-700', 'bg-white dark:bg-nero-900', diff --git a/src/components/Drawer.tsx b/src/components/Drawer.tsx index 5f3609f..5d32b90 100644 --- a/src/components/Drawer.tsx +++ b/src/components/Drawer.tsx @@ -32,7 +32,7 @@ const Drawer: React.FC = ({ isOpen, onClose, children }) => {
= ({ isOpen, onClose, children }) => { > {children} -
diff --git a/src/components/GridList.tsx b/src/components/GridList.tsx index fa92bd1..7ac55f0 100644 --- a/src/components/GridList.tsx +++ b/src/components/GridList.tsx @@ -1,7 +1,7 @@ interface GridListProps { items: T[]; colNum: number; - onAddItem: () => void; + onAddItem?: () => void; itemComponent: React.ComponentType<{ item: T }>; } diff --git a/src/components/forms/CreateEstimationSessionForm.tsx b/src/components/forms/CreateEstimationSessionForm.tsx index 9faaf0d..8f3ad2b 100644 --- a/src/components/forms/CreateEstimationSessionForm.tsx +++ b/src/components/forms/CreateEstimationSessionForm.tsx @@ -19,8 +19,8 @@ const CreateEstimationSessionForm: React.FC< }, onSubmit: async ({ value }) => { await estimationSessions?.add({ - Name: value.name, - UserId: user.current?.$id, + name: value.name, + userId: user.current?.$id, }); onCreated(); }, @@ -51,7 +51,9 @@ const CreateEstimationSessionForm: React.FC< /> )} /> - + ); diff --git a/src/constants.ts b/src/constants.ts index 0f1a84b..2c03033 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,5 +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'; +export const APPWRITE_DATABASE_ID = '670402eb000f5aff721f'; +export const APPWRITE_ESTIMATION_SESSION_COLLECTION_ID = '670402f60023cb78d441'; diff --git a/src/lib/context/estimation.tsx b/src/lib/context/estimation.tsx new file mode 100644 index 0000000..71246fe --- /dev/null +++ b/src/lib/context/estimation.tsx @@ -0,0 +1,185 @@ +import { + createContext, + PropsWithChildren, + useContext, + useEffect, + useState, +} from 'react'; +import { client, databases } from '../appwrite'; +import { DatabaseModels, EntityModels } from '../types'; +import { + APPWRITE_DATABASE_ID, + APPWRITE_ESTIMATION_SESSION_COLLECTION_ID, +} from '../../constants'; +import { useUser } from './user'; +import { EstimationSessionTicket } from '../types/entityModels'; + +interface EstimationContextType { + setSessionId: (sessionId: string) => void; + setActiveTicket: (ticketId: string) => Promise; + setRevealed: (revealed: boolean) => Promise; + setVote: (estimate: string) => Promise; + createTicket: (ticket: Omit) => Promise; + currentSessionData?: EntityModels.EstimationSession; +} + +const EstimationContext = createContext( + undefined, +); + +export const useEstimationContext = () => { + return useContext(EstimationContext); +}; + +const mapEstimationSession = ( + data: DatabaseModels.EstimationSession, + { userId }: { userId?: string }, +) => { + const sessionState = JSON.parse( + data.sessionState, + ) as EntityModels.SessionState; + + const tickets = data.tickets.map( + (ticket) => JSON.parse(ticket), + ); + + const result: EntityModels.EstimationSession = { + id: data.$id, + name: data.name, + userId: data.userId, + tickets, + sessionState: { + ...sessionState, + currentPlayerVote: sessionState.votes.find((x) => x.userId === userId) + ?.estimate, + currentTicket: tickets.find((x) => x.id === sessionState.currentTicketId), + }, + }; + + console.log({ + result, + userId, + }); + + return result; +}; + +export const EstimationContextProvider = (props: PropsWithChildren) => { + const [sessionId, setSessionId] = useState(''); + const [currentSessionData, setCurrentSessionData] = + useState(); + + const { current: userData } = useUser(); + + useEffect(() => { + if (!sessionId || !userData) { + return () => {}; + } + + databases + .getDocument( + APPWRITE_DATABASE_ID, + APPWRITE_ESTIMATION_SESSION_COLLECTION_ID, + sessionId, + ) + .then((payload) => { + const userId = userData?.$id ?? ''; // TODO: Not sure if this is the user id or session + setCurrentSessionData(mapEstimationSession(payload, { userId })); + }); + + return client.subscribe( + [ + `databases.${APPWRITE_DATABASE_ID}.collections.${APPWRITE_ESTIMATION_SESSION_COLLECTION_ID}.documents.${sessionId}`, + ], + ({ payload }) => { + const userId = userData?.$id ?? ''; // TODO: Not sure if this is the user id or session + setCurrentSessionData(mapEstimationSession(payload, { userId })); + }, + ); + }, [sessionId, userData]); + + const setSessionIdFunc = (newSessionId: string) => { + if (sessionId !== newSessionId) { + setSessionId(newSessionId); + } + }; + + const updateSessionState = async ( + data: Partial, + ) => { + await databases.updateDocument( + APPWRITE_DATABASE_ID, + APPWRITE_ESTIMATION_SESSION_COLLECTION_ID, + sessionId, + { + sessionState: JSON.stringify({ + ...currentSessionData?.sessionState, + ...data, + }), + }, + ); + }; + + const setActiveTicket = async (ticketId: string) => { + await updateSessionState({ + currentTicketId: ticketId, + votes: [], + votesRevealed: false, + }); + }; + + const setVote = async (estimate: string) => { + const userId = userData?.$id ?? ''; // TODO: Not sure if this is the user id or session + await updateSessionState({ + votes: currentSessionData?.sessionState.votes + .filter((x) => x.userId !== userId) + .concat([ + { + estimate: estimate, + userId: userId, + }, + ]), + }); + }; + + const setRevealed = async (revealed: boolean) => { + await updateSessionState({ + votesRevealed: revealed, + }); + }; + + const createTicket = async (ticket: Omit) => { + const newTicketsValue = currentSessionData?.tickets + .concat([ + { + ...ticket, + id: crypto.randomUUID(), + }, + ]) + .map((x) => JSON.stringify(x)); + + await databases.updateDocument( + APPWRITE_DATABASE_ID, + APPWRITE_ESTIMATION_SESSION_COLLECTION_ID, + sessionId, + { + tickets: newTicketsValue, + }, + ); + }; + + return ( + + {props.children} + + ); +}; diff --git a/src/lib/context/estimationSession.tsx b/src/lib/context/estimationSession.tsx index 99f57f1..57e21a5 100644 --- a/src/lib/context/estimationSession.tsx +++ b/src/lib/context/estimationSession.tsx @@ -13,23 +13,23 @@ import { } from '../../constants'; interface EstimationSessionType extends Models.Document { - UserId: string; - Name: string; - Tickets: string[]; - SessionState: string; + userId: string; + name: string; + tickets: string[]; + sessionState: string; } interface EstimationSessionTicket { - Id: string; - Name: string; + id: string; + name: string; } interface SessionStateType { - CurrentTicketId: string; - VotesRevealed: boolean; - Votes: { - UserId: string; - Estimate: number; + currentTicketId: string; + votesRevealed: boolean; + votes: { + userId: string; + estimate: number; }[]; } @@ -41,7 +41,7 @@ interface EstimationSessionsContextType { remove: (id: string) => Promise; addTicket: ( sessionId: string, - ticket: Omit, + ticket: Omit, ) => Promise; getTickets: (sessionId: string) => EstimationSessionTicket[]; selectTicket: (sessionId: string, ticketId: string) => Promise; @@ -98,7 +98,7 @@ export function EstimationSessionProvider(props: PropsWithChildren) { const addTicket = async ( sessionId: string, - ticket: Omit, + ticket: Omit, ) => { const currentSession = estimationSessions.find((x) => x.$id === sessionId); const response = await databases.updateDocument( @@ -106,10 +106,10 @@ export function EstimationSessionProvider(props: PropsWithChildren) { APPWRITE_ESTIMATION_SESSION_COLLECTION_ID, sessionId, { - Tickets: currentSession?.Tickets.concat([ + tickets: currentSession?.tickets.concat([ JSON.stringify({ ...ticket, - Id: crypto.randomUUID(), + id: crypto.randomUUID(), }), ]), }, @@ -126,7 +126,7 @@ export function EstimationSessionProvider(props: PropsWithChildren) { return ( estimationSessions .find((x) => x.$id === sessionId) - ?.Tickets.map((x) => JSON.parse(x)) ?? [] + ?.tickets.map((x) => JSON.parse(x)) ?? [] ); }; @@ -136,8 +136,8 @@ export function EstimationSessionProvider(props: PropsWithChildren) { APPWRITE_ESTIMATION_SESSION_COLLECTION_ID, sessionId, { - SessionState: JSON.stringify({ - CurrentTicketId: ticketId, + sessionState: JSON.stringify({ + currentTicketId: ticketId, }), }, ); @@ -151,7 +151,7 @@ export function EstimationSessionProvider(props: PropsWithChildren) { const getState = (sessionId: string): SessionStateType => { return JSON.parse( - estimationSessions.find((x) => x.$id === sessionId)?.SessionState ?? '{}', + estimationSessions.find((x) => x.$id === sessionId)?.sessionState ?? '{}', ); }; @@ -162,12 +162,12 @@ export function EstimationSessionProvider(props: PropsWithChildren) { userId: string, ) => { const currentState = getState(sessionId); - const newVotes = (currentState.Votes ?? []) - .filter((x) => x.UserId !== userId) + const newVotes = (currentState.votes ?? []) + .filter((x) => x.userId !== userId) .concat([ { - Estimate: estimate, - UserId: userId, + estimate: estimate, + userId: userId, }, ]); const response = await databases.updateDocument( @@ -175,9 +175,9 @@ export function EstimationSessionProvider(props: PropsWithChildren) { APPWRITE_ESTIMATION_SESSION_COLLECTION_ID, sessionId, { - SessionState: JSON.stringify({ - CurrentTicketId: ticketId, - Votes: newVotes, + sessionState: JSON.stringify({ + currentTicketId: ticketId, + votes: newVotes, }), }, ); @@ -196,7 +196,7 @@ export function EstimationSessionProvider(props: PropsWithChildren) { APPWRITE_ESTIMATION_SESSION_COLLECTION_ID, sessionId, { - SessionState: JSON.stringify({ + sessionState: JSON.stringify({ ...currentState, VotesRevealed: true, }), @@ -217,8 +217,12 @@ export function EstimationSessionProvider(props: PropsWithChildren) { [Query.orderDesc('$createdAt'), Query.limit(10)], ); setEstimationSessions(response.documents); + }; - client.subscribe( + useEffect(() => { + init(); + + return client.subscribe( [ `databases.${APPWRITE_DATABASE_ID}.collections.${APPWRITE_ESTIMATION_SESSION_COLLECTION_ID}.documents`, ], @@ -231,10 +235,6 @@ export function EstimationSessionProvider(props: PropsWithChildren) { ); }, ); - }; - - useEffect(() => { - init(); }, []); return ( diff --git a/src/lib/types/databaseModels.ts b/src/lib/types/databaseModels.ts new file mode 100644 index 0000000..d5ff72e --- /dev/null +++ b/src/lib/types/databaseModels.ts @@ -0,0 +1,10 @@ +import { Models } from 'appwrite'; + +interface EstimationSession extends Models.Document { + userId: string; + name: string; + tickets: string[]; + sessionState: string; +} + +export type { EstimationSession }; diff --git a/src/lib/types/entityModels.ts b/src/lib/types/entityModels.ts new file mode 100644 index 0000000..1d444cb --- /dev/null +++ b/src/lib/types/entityModels.ts @@ -0,0 +1,32 @@ +interface EstimationSession { + id: string; + userId: string; + name: string; + tickets: EstimationSessionTicket[]; + sessionState: SessionState; +} + +interface EstimationSessionTicket { + id: string; + name: string; +} + +interface SessionState { + currentTicketId?: string; + votesRevealed: boolean; + votes: PlayerVote[]; + currentPlayerVote?: string; + currentTicket?: EstimationSessionTicket; +} + +interface PlayerVote { + userId: string; + estimate: string; +} + +export type { + EstimationSession, + EstimationSessionTicket, + SessionState, + PlayerVote, +}; diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts new file mode 100644 index 0000000..95b367e --- /dev/null +++ b/src/lib/types/index.ts @@ -0,0 +1,2 @@ +export * as DatabaseModels from './databaseModels'; +export * as EntityModels from './entityModels'; diff --git a/src/main.tsx b/src/main.tsx index 5343a71..00dd74c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -10,8 +10,9 @@ import { import Home from './pages/Home'; import { UserProvider } from './lib/context/user'; import Login from './pages/Login'; -import EstimationSession from './pages/EstimationSession'; import { EstimationSessionProvider } from './lib/context/estimationSession'; +import { EstimationContextProvider } from './lib/context/estimation'; +import Estimation from './pages/Estimation/Estimation'; const rootRoute = createRootRoute(); @@ -29,7 +30,7 @@ const loginRoute = createRoute({ const estimationSessionRoute = createRoute({ path: 'estimate/session/$sessionId', - component: EstimationSession, + component: Estimation, getParentRoute: () => rootRoute, }); @@ -49,11 +50,13 @@ declare module '@tanstack/react-router' { createRoot(document.getElementById('root')!).render( - - {/* TODO: Move ctx providers to layout */} - - - - + + + + {/* TODO: Move ctx providers to layout */} + + + + , ); diff --git a/src/pages/Estimation/Estimation.tsx b/src/pages/Estimation/Estimation.tsx new file mode 100644 index 0000000..8d80fe8 --- /dev/null +++ b/src/pages/Estimation/Estimation.tsx @@ -0,0 +1,92 @@ +import React, { useEffect, useState } from 'react'; +import { useEstimationContext } from '../../lib/context/estimation'; +import { getRouteApi } from '@tanstack/react-router'; +import TaskSidebar from './components/TaskSidebar'; +import VoteSelection from './components/VoteSelection'; +import VoteList from './components/VoteList'; +import { Button, ButtonColor, Drawer } from '../../components'; +import CreateTicketForm from './components/CreateTicketForm'; + +const fibonacciSequence = [1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 100]; + +const route = getRouteApi('/estimate/session/$sessionId'); + +const Estimation: React.FC = () => { + const { sessionId } = route.useParams(); + const estimationState = useEstimationContext(); + useEffect(() => estimationState?.setSessionId(sessionId), [sessionId]); + const [isDrawerOpen, setDrawerOpen] = useState(false); + + if (!estimationState?.currentSessionData) { + return null; // TODO: Add a loader + } + + const { + setActiveTicket, + setVote, + setRevealed, + createTicket, + currentSessionData: { + tickets: tickets, + sessionState: { + votesRevealed: revealed, + votes: votes, + currentPlayerVote, + currentTicket, + }, + }, + } = estimationState; + + return ( +
+ setActiveTicket(ticket.id)} + onAddTicket={() => setDrawerOpen(true)} + /> + +
+ {currentTicket ? ( + <> +

{currentTicket.name}

+

+ {currentTicket.id} +

+ + setVote(vote)} + options={fibonacciSequence.map((x) => `${x}`)} + value={currentPlayerVote} + /> + + + +
+ +
+ + ) : ( +

Select a task to see the details and estimate.

+ )} +
+ + setDrawerOpen(false)}> + { + await createTicket(ticket); + setDrawerOpen(false); + }} + /> + +
+ ); +}; + +export default Estimation; diff --git a/src/pages/Estimation/components/CreateTicketForm.tsx b/src/pages/Estimation/components/CreateTicketForm.tsx new file mode 100644 index 0000000..a3eabbd --- /dev/null +++ b/src/pages/Estimation/components/CreateTicketForm.tsx @@ -0,0 +1,51 @@ +import { useForm } from '@tanstack/react-form'; +import { Button, Input } from '../../../components'; +import { EstimationSessionTicket } from '../../../lib/types/entityModels'; + +interface CreateTicketFormProps { + onCreate: (ticket: Omit) => Promise; +} + +const CreateTicketForm: React.FC = ({ onCreate }) => { + const form = useForm({ + defaultValues: { + name: '', + }, + onSubmit: async ({ value }) => { + await onCreate(value); + }, + }); + + return ( +
+

Create a New Ticket

+
{ + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + > + ( + field.handleChange(e.target.value)} + /> + )} + /> + + +
+ ); +}; + +export default CreateTicketForm; diff --git a/src/pages/Estimation/components/TaskSidebar.tsx b/src/pages/Estimation/components/TaskSidebar.tsx new file mode 100644 index 0000000..c1d2784 --- /dev/null +++ b/src/pages/Estimation/components/TaskSidebar.tsx @@ -0,0 +1,36 @@ +import { Card, GridList } from '../../../components'; +import { EstimationSessionTicket } from '../../../lib/types/entityModels'; + +interface TaskSidebarProps { + className?: string; + tickets: EstimationSessionTicket[]; + onSelectTicket: (ticket: EstimationSessionTicket) => void; + onAddTicket: () => void; +} + +const TaskSidebar: React.FC = ({ + className, + tickets, + onSelectTicket, + onAddTicket, +}) => { + return ( +
+ ( + onSelectTicket(item)} + /> + )} + onAddItem={onAddTicket} + /> +
+ ); +}; + +export default TaskSidebar; diff --git a/src/pages/Estimation/components/VoteList.tsx b/src/pages/Estimation/components/VoteList.tsx new file mode 100644 index 0000000..34cdae8 --- /dev/null +++ b/src/pages/Estimation/components/VoteList.tsx @@ -0,0 +1,29 @@ +import { Card, GridList } from '../../../components'; +import { PlayerVote } from '../../../lib/types/entityModels'; + +interface VoteListProps { + className?: string; + votes: PlayerVote[]; + revealed: boolean; +} + +const VoteList: React.FC = ({ className, votes, revealed }) => { + return ( +
+

Player Votes

+ ( + + )} + items={votes} + /> +
+ ); +}; + +export default VoteList; diff --git a/src/pages/Estimation/components/VoteSelection.tsx b/src/pages/Estimation/components/VoteSelection.tsx new file mode 100644 index 0000000..fea383d --- /dev/null +++ b/src/pages/Estimation/components/VoteSelection.tsx @@ -0,0 +1,37 @@ +import classNames from 'classnames'; + +interface VoteSelectionProps { + className: string; + value?: string; + options: string[]; + onSelect: (vote: string) => void; +} + +const VoteSelection: React.FC = ({ + className, + value, + options, + onSelect, +}) => { + const getItemClassName = (option: string) => + classNames('rounded-md px-4 py-2 text-white transition-colors', { + 'bg-indigo-800': value !== option, + 'bg-indigo-600 hover:bg-indigo-500': value === option, + }); + + return ( +
+ {options.map((option) => ( + + ))} +
+ ); +}; + +export default VoteSelection; diff --git a/src/pages/EstimationSession.tsx b/src/pages/EstimationSession.tsx deleted file mode 100644 index 6282520..0000000 --- a/src/pages/EstimationSession.tsx +++ /dev/null @@ -1,110 +0,0 @@ -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/pages/Home.css b/src/pages/Home.css index cf763fc..1d6fa2b 100644 --- a/src/pages/Home.css +++ b/src/pages/Home.css @@ -1,10 +1,3 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - .logo { height: 6em; padding: 1.5em; diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 08ac1bc..355f74f 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -47,7 +47,7 @@ function Home() { itemComponent={({ item }) => ( { navigate({