diff --git a/package-lock.json b/package-lock.json index e75776d..bf00415 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1323,6 +1323,24 @@ "vite": "^4.2.0 || ^5.0.0" } }, + "node_modules/@xstate/react": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@xstate/react/-/react-4.1.3.tgz", + "integrity": "sha512-zhE+ZfrcCR87bu71Rkh5Z5ruZBivR/7uD/dkelzJqjQdI45IZc9DqTI8lL4Cg5+VN2p5k86KxDsusqW1kW11Tg==", + "dependencies": { + "use-isomorphic-layout-effect": "^1.1.2", + "use-sync-external-store": "^1.2.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "xstate": "^5.18.2" + }, + "peerDependenciesMeta": { + "xstate": { + "optional": true + } + } + }, "node_modules/acorn": { "version": "8.11.3", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", @@ -4384,6 +4402,19 @@ "punycode": "^2.1.0" } }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz", + "integrity": "sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/use-sync-external-store": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", @@ -4624,6 +4655,15 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true }, + "node_modules/xstate": { + "version": "5.18.2", + "resolved": "https://registry.npmjs.org/xstate/-/xstate-5.18.2.tgz", + "integrity": "sha512-hab5VOe29D0agy8/7dH1lGw+7kilRQyXwpaChoMu4fe6rDP+nsHYhDYKfS2O4iXE7myA98TW6qMEudj/8NXEkA==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/xstate" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -4823,10 +4863,13 @@ } }, "packages/xstate": { + "name": "@timo/xstate", "version": "1.0.0", "dependencies": { + "@xstate/react": "^4.1.3", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "xstate": "^5.18.2" }, "devDependencies": { "@types/react": "^18.2.64", diff --git a/packages/jotai/src/App.jsx b/packages/jotai/src/App.jsx index 9abc093..e45e1d9 100644 --- a/packages/jotai/src/App.jsx +++ b/packages/jotai/src/App.jsx @@ -13,7 +13,6 @@ import Router from './components/Router'; const routes = [ { path: '/', name: 'Entries' }, { path: '/login', name: 'Login' }, - { path: '/register', name: 'Register' }, { path: '/new', name: 'NewEntry' }, { path: '/profile', name: 'Profile' } ]; diff --git a/packages/mobx/src/App.jsx b/packages/mobx/src/App.jsx index 1632b33..5596517 100644 --- a/packages/mobx/src/App.jsx +++ b/packages/mobx/src/App.jsx @@ -16,7 +16,6 @@ import Profile from './routes/Profile/Profile'; const routes = [ { path: '/', name: 'Entries' }, { path: '/login', name: 'Login' }, - { path: '/register', name: 'Register' }, { path: '/new', name: 'NewEntry' }, { path: '/profile', name: 'Profile' } ]; diff --git a/packages/react/src/App.jsx b/packages/react/src/App.jsx index a945235..fbcbd95 100644 --- a/packages/react/src/App.jsx +++ b/packages/react/src/App.jsx @@ -13,7 +13,6 @@ import Profile from './routes/Profile/Profile'; const routes = [ { path: '/', name: 'Entries' }, { path: '/login', name: 'Login' }, - { path: '/register', name: 'Register' }, { path: '/new', name: 'NewEntry' }, { path: '/profile', name: 'Profile' } ]; diff --git a/packages/tanstack-query/src/App.jsx b/packages/tanstack-query/src/App.jsx index 77d9680..1795471 100644 --- a/packages/tanstack-query/src/App.jsx +++ b/packages/tanstack-query/src/App.jsx @@ -17,7 +17,6 @@ import Profile from './routes/Profile/Profile'; const routes = [ { path: '/', name: 'Entries' }, { path: '/login', name: 'Login' }, - { path: '/register', name: 'Register' }, { path: '/new', name: 'NewEntry' }, { path: '/profile', name: 'Profile' } ]; diff --git a/packages/valtio/src/App.jsx b/packages/valtio/src/App.jsx index 1632b33..5596517 100644 --- a/packages/valtio/src/App.jsx +++ b/packages/valtio/src/App.jsx @@ -16,7 +16,6 @@ import Profile from './routes/Profile/Profile'; const routes = [ { path: '/', name: 'Entries' }, { path: '/login', name: 'Login' }, - { path: '/register', name: 'Register' }, { path: '/new', name: 'NewEntry' }, { path: '/profile', name: 'Profile' } ]; diff --git a/packages/xstate/package.json b/packages/xstate/package.json index 210ad62..f7bc784 100644 --- a/packages/xstate/package.json +++ b/packages/xstate/package.json @@ -9,8 +9,10 @@ "preview": "vite preview" }, "dependencies": { + "@xstate/react": "^4.1.3", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "xstate": "^5.18.2" }, "devDependencies": { "@types/react": "^18.2.64", diff --git a/packages/xstate/src/App.jsx b/packages/xstate/src/App.jsx index a945235..b4910a9 100644 --- a/packages/xstate/src/App.jsx +++ b/packages/xstate/src/App.jsx @@ -1,67 +1,47 @@ -import Router from '@timo/common/components/Router'; -import UserContextProvider from '@timo/common/context/UserContextProvider'; import Container from '@timo/common/components/Container'; -import Title from '@timo/common/components/Title'; -import TopBarWithUser from '@timo/common/contextualComponents/TopBarWithUser'; -import ProtectedRoute from '@timo/common/contextualComponents/ProtectedRoute'; import Login from './routes/Login/Login'; import Entries from './routes/Entries/Entries'; import NewEntry from './routes/NewEntry/NewEntry'; import Profile from './routes/Profile/Profile'; - -const routes = [ - { path: '/', name: 'Entries' }, - { path: '/login', name: 'Login' }, - { path: '/register', name: 'Register' }, - { path: '/new', name: 'NewEntry' }, - { path: '/profile', name: 'Profile' } -]; +import TopBarWithUser from './contextualComponents/TopBarWithUser'; +import MachineContextProvider from './context/MachineContext'; +import Router from './components/Router'; const App = () => ( - - - {(routeName, history) => { + + + {(routeName) => { let pageComponent = null; switch (routeName) { - case 'Login': - pageComponent = ; + case 'login': + pageComponent = ; break; - case 'NewEntry': + case 'newEntry': pageComponent = ( - - - + ); break; - case 'Entries': + case 'entries': pageComponent = ( - - - + ); break; - case 'Profile': + case 'profile': pageComponent = ( - - - + ); break; - default: - pageComponent = ( - Page not found - ); } return ( - + {pageComponent} ); }} - + ); export default App; diff --git a/packages/xstate/src/components/Router.jsx b/packages/xstate/src/components/Router.jsx new file mode 100644 index 0000000..08eae91 --- /dev/null +++ b/packages/xstate/src/components/Router.jsx @@ -0,0 +1,17 @@ +import PropTypes from 'prop-types'; +import useRootMachineState from '../hooks/useRootMachineState'; + +const Router = ({ children }) => { + const state = useRootMachineState(state => state.value); + const route = state?.authenticated || state?.unauthenticated; + if (route && route !== 'unknown') { + return children(route); + } + return null; +}; + +Router.propTypes = { + children: PropTypes.func.isRequired +}; + +export default Router; \ No newline at end of file diff --git a/packages/xstate/src/components/Timer.jsx b/packages/xstate/src/components/Timer.jsx new file mode 100644 index 0000000..d7e2d22 --- /dev/null +++ b/packages/xstate/src/components/Timer.jsx @@ -0,0 +1,17 @@ +import styles from '@timo/common/components/Timer/Timer.module.css'; +import useChildMachineState from '../hooks/useChildMachineState'; + +const Timer = () => { + const timerValue = useChildMachineState('newEntry', state => state.context.timerValue); + + // Format duration to HH:MM:SS + const formattedValue = new Date(timerValue * 1000).toISOString().slice(11, 19); + + return ( +
+ {formattedValue} +
+ ); +}; + +export default Timer; \ No newline at end of file diff --git a/packages/xstate/src/context/MachineContext.jsx b/packages/xstate/src/context/MachineContext.jsx new file mode 100644 index 0000000..b25fbbd --- /dev/null +++ b/packages/xstate/src/context/MachineContext.jsx @@ -0,0 +1,23 @@ +import PropTypes from 'prop-types'; +import { createActor } from 'xstate'; +import { createContext } from 'react'; +import root from '../machines/root'; + +const rootActor = createActor(root, { systemId: 'root' }); +rootActor.start(); + +export const MachineContext = createContext(); + +const MachineContextProvider = ({ children }) => { + return ( + + {children} + + ); +}; + +MachineContextProvider.propTypes = { + children: PropTypes.node.isRequired +}; + +export default MachineContextProvider; \ No newline at end of file diff --git a/packages/xstate/src/contextualComponents/TopBarWithUser.jsx b/packages/xstate/src/contextualComponents/TopBarWithUser.jsx new file mode 100644 index 0000000..d1cb48a --- /dev/null +++ b/packages/xstate/src/contextualComponents/TopBarWithUser.jsx @@ -0,0 +1,21 @@ +import TopBar from '@timo/common/components/TopBar'; +import useRootMachine from '../hooks/useRootMachine'; +import useRootMachineState from '../hooks/useRootMachineState'; + +const TopBarWithUser = () => { + const rootMachine = useRootMachine(); + const userData = useRootMachineState(state => state.context.userData); + + return ( + rootMachine.send({ type: 'pushRoute', route: 'entries' })} + onAvatarClick={() => rootMachine.send({ type: 'pushRoute', route: 'profile' })} + /> + ); +}; + +export default TopBarWithUser; diff --git a/packages/xstate/src/hooks/useChildMachine.js b/packages/xstate/src/hooks/useChildMachine.js new file mode 100644 index 0000000..59b0f65 --- /dev/null +++ b/packages/xstate/src/hooks/useChildMachine.js @@ -0,0 +1,9 @@ +import { useSelector } from '@xstate/react'; +import useRootMachine from './useRootMachine'; + +const useChildMachine = (childId) => { + const rootMachine = useRootMachine(); + return useSelector(rootMachine, (state) => state.children[childId]); +}; + +export default useChildMachine; \ No newline at end of file diff --git a/packages/xstate/src/hooks/useChildMachineState.js b/packages/xstate/src/hooks/useChildMachineState.js new file mode 100644 index 0000000..782dd21 --- /dev/null +++ b/packages/xstate/src/hooks/useChildMachineState.js @@ -0,0 +1,9 @@ +import { useSelector } from '@xstate/react'; +import useChildMachine from './useChildMachine'; + +const useChildMachineState = (childId, selector) => { + const machine = useChildMachine(childId); + return useSelector(machine, selector); +}; + +export default useChildMachineState; \ No newline at end of file diff --git a/packages/xstate/src/hooks/useRootMachine.js b/packages/xstate/src/hooks/useRootMachine.js new file mode 100644 index 0000000..082c661 --- /dev/null +++ b/packages/xstate/src/hooks/useRootMachine.js @@ -0,0 +1,8 @@ +import { useContext } from 'react'; +import { MachineContext } from '../context/MachineContext'; + +const useRootMachine = () => { + return useContext(MachineContext); +}; + +export default useRootMachine; \ No newline at end of file diff --git a/packages/xstate/src/hooks/useRootMachineState.js b/packages/xstate/src/hooks/useRootMachineState.js new file mode 100644 index 0000000..2db84bf --- /dev/null +++ b/packages/xstate/src/hooks/useRootMachineState.js @@ -0,0 +1,9 @@ +import { useSelector } from '@xstate/react'; +import useRootMachine from './useRootMachine'; + +const useRootMachineState = (selector) => { + const rootMachine = useRootMachine(); + return useSelector(rootMachine, selector); +}; + +export default useRootMachineState; \ No newline at end of file diff --git a/packages/xstate/src/machines/changePassword.js b/packages/xstate/src/machines/changePassword.js new file mode 100644 index 0000000..90bda1c --- /dev/null +++ b/packages/xstate/src/machines/changePassword.js @@ -0,0 +1,55 @@ +import { setup, assign, fromPromise } from 'xstate'; +import { updatePassword } from '@timo/common/api'; + +const changePasswordMachine = setup({ + actors: { + updatePassword: fromPromise(async ({ input }) => updatePassword(input)) + } +}).createMachine({ + id: 'changePassword', + initial: 'idle', + context: ({ input }) => ({ + username: input.username, + statusMessage: null + }), + states: { + 'idle': { + on: { + 'save': { + target: 'saving' + } + } + }, + 'saving': { + entry: [ + assign({ + statusMessage: 'Saving...' + }) + ], + invoke: [ + { + src: 'updatePassword', + input: ({ context, event }) => ({ + username: context.username, + password: event.password, + newPassword: event.newPassword + }), + onDone: { + target: 'idle', + actions: assign({ + statusMessage: 'Password updated' + }) + }, + onError: { + target: 'idle', + actions: assign({ + statusMessage: ({ event }) => event.error.message + }) + } + } + ] + } + } +}); + +export default changePasswordMachine; \ No newline at end of file diff --git a/packages/xstate/src/machines/customizeUser.js b/packages/xstate/src/machines/customizeUser.js new file mode 100644 index 0000000..c090fa2 --- /dev/null +++ b/packages/xstate/src/machines/customizeUser.js @@ -0,0 +1,87 @@ +import { setup, assign, fromPromise, sendTo } from 'xstate'; +import { updateUser } from '@timo/common/api'; + +const customizeUserMachine = setup({ + actors: { + updateUser: fromPromise(async ({ input }) => updateUser(input)) + } +}).createMachine({ + id: 'customizeUser', + initial: 'idle', + context: ({ input }) => ({ + userId: input.userId, + username: input.username, + avatar_background: input.avatar_background, + avatar_character: input.avatar_character, + statusMessage: null + }), + states: { + 'idle': { + on: { + 'changeAvatarCharacter': { + actions: assign(({ event }) => ({ + avatar_character: event.value + })) + }, + 'changeAvatarBackground': { + actions: assign(({ event }) => ({ + avatar_background: event.value + })) + }, + 'changeUsername': { + actions: assign(({ event }) => ({ + username: event.value + })) + }, + 'save': { + target: 'saving' + } + } + }, + 'saving': { + entry: [ + assign({ + statusMessage: 'Saving...' + }) + ], + invoke: [ + { + src: 'updateUser', + input: ({ context }) => ({ + id: context.userId, + username: context.username, + avatar_character: context.avatar_character, + avatar_background: context.avatar_background + }), + onDone: { + target: 'idle', + actions: [ + assign({ + statusMessage: 'Profile updated' + }), + sendTo( + ({ system }) => system.get('root'), + ({ context }) => ({ + type: 'updateUserData', + params: { + username: context.username, + avatar_character: context.avatar_character, + avatar_background: context.avatar_background + } + }) + ) + ] + }, + onError: { + target: 'idle', + actions: assign({ + statusMessage: ({ event }) => event.error.message + }) + } + } + ] + } + } +}); + +export default customizeUserMachine; \ No newline at end of file diff --git a/packages/xstate/src/machines/entries.js b/packages/xstate/src/machines/entries.js new file mode 100644 index 0000000..0a7bbef --- /dev/null +++ b/packages/xstate/src/machines/entries.js @@ -0,0 +1,158 @@ +import { setup, fromPromise, assign } from 'xstate'; +import { listEntries, updateEntry, deleteEntry } from '@timo/common/api'; +import getDateString from '@timo/common/utils/getDateString'; + +const now = new Date(); +const firstDateOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); +const lastDateOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59); + +const getTotalDuration = (entries) => { + return entries.reduce((total, entry) => { + const diff = new Date(entry.end_time) - new Date(entry.start_time); + return total + diff; + }, 0); +}; + +const getEntriesGroupedByDate = (entries) => { + const formatter = new Intl.DateTimeFormat('default', { dateStyle: 'medium' }); + const groupedEntries = entries + .toReversed() // Entries are sorted by id which is ascending, so we need to reverse them + .reduce((grouped, entry) => { + const date = formatter.format(new Date(entry.start_time)); + if (!grouped[date]) { + grouped[date] = []; + } + grouped[date].push(entry); // Order of object keys is not guaranteed but YOLO + return grouped; + }, {}); + return Object.entries(groupedEntries) + .map(([date, dayEntries]) => [ + date, + dayEntries.toReversed(), // Day entries are descending after being grouped, reverse them + getTotalDuration(dayEntries) + ]); +}; + +const entriesMachine = setup({ + actors: { + getEntries: fromPromise(async ({ input }) => listEntries(input)), + updateEntry: fromPromise(async ({ input }) => updateEntry(input)), + deleteEntry: fromPromise(async ({ input }) => deleteEntry(input)) + } +}).createMachine({ + id: 'entries', + initial: 'loading', + context: { + groupedEntries: [], + totalDuration: 0, + statusMessage: null, + filter: { + startDate: getDateString(firstDateOfMonth), + endDate: getDateString(lastDateOfMonth) + }, + itemStatusMessage: {} + }, + states: { + 'loading': { + invoke: { + src: 'getEntries', + input: ({ context }) => ({ + from: context.filter.startDate, + to: context.filter.endDate + }), + onDone: { + target: 'idle', + actions: assign(({ event }) => ({ + groupedEntries: getEntriesGroupedByDate(event.output), + totalDuration: getTotalDuration(event.output), + statusMessage: event.output.length === 0 ? 'No entries found' : null + })) + }, + onError: { + target: 'idle', + actions: assign({ + statusMessage: ({ event }) => event.error.message + }) + } + } + }, + 'idle': { + on: { + 'filter': { + target: 'loading', + actions: assign(({ event }) => ({ + filter: { + startDate: event.startDate, + endDate: event.endDate + } + })) + }, + 'refresh': { + target: 'loading' + }, + 'updateEntry': { + target: 'updating-entry' + }, + 'deleteEntry': { + target: 'deleting-entry' + } + } + }, + 'updating-entry': { + entry: [ + assign({ + itemStatusMessage: ({ event }) => ({ + [event.id]: 'Saving...' + }) + }) + ], + invoke: { + src: 'updateEntry', + input: ({ event }) => event.updatedEntry, + onDone: { + target: 'loading', + actions: assign({ + itemStatusMessage: ({ event }) => ({ + [event.updatedEntry.id]: null + }) + }) + }, + onError: { + target: 'idle', + actions: assign({ + statusMessage: ({ event }) => event.error.message + }) + } + } + }, + 'deleting-entry': { + entry: [ + assign({ + itemStatusMessage: ({ event }) => ({ + [event.entryId]: 'Deleting...' + }) + }) + ], + invoke: { + src: 'deleteEntry', + input: ({ event }) => event.entryId, + onDone: { + target: 'loading', + actions: assign({ + itemStatusMessage: ({ event }) => ({ + [event.id]: null + }) + }) + }, + onError: { + target: 'idle', + actions: assign({ + statusMessage: ({ event }) => event.error.message + }) + } + } + } + } +}); + +export default entriesMachine; \ No newline at end of file diff --git a/packages/xstate/src/machines/login.js b/packages/xstate/src/machines/login.js new file mode 100644 index 0000000..c5d2708 --- /dev/null +++ b/packages/xstate/src/machines/login.js @@ -0,0 +1,73 @@ +import { setup, fromPromise, assign, sendTo } from 'xstate'; +import { login, register } from '@timo/common/api'; + +const loginMachine = setup({ + actors: { + login: fromPromise(async ({ input }) => login(input)), + register: fromPromise(async ({ input }) => register(input)) + }, + actions: { + authenticate: sendTo( + ({ system }) => system.get('root'), + ({ event }) => ({ type: 'authenticate', params: event.output }) + ) + } +}).createMachine({ + id: 'login', + context: { + statusMessage: null + }, + initial: 'idle', + states: { + 'idle': { + on: { + 'login': { + target: 'logging-in', + actions: assign({ + statusMessage: 'Logging in...' + }) + }, + 'register': { + target: 'registering', + actions: assign({ + statusMessage: 'Registering...' + }) + } + } + }, + 'logging-in': { + invoke: { + src: 'login', + input: ({ event }) => ({ username: event.username, password: event.password }), + onDone: { + target: 'idle', + actions: { type: 'authenticate' } + }, + onError: { + target: 'idle', + actions: assign({ + statusMessage: ({ event }) => event.error.message + }) + } + } + }, + 'registering': { + invoke: { + src: 'register', + input: ({ event }) => ({ username: event.username, password: event.password }), + onDone: { + target: 'idle', + actions: { type: 'authenticate'} + }, + onError: { + target: 'idle', + actions: assign({ + statusMessage: ({ event }) => event.error.message + }) + } + } + } + } +}); + +export default loginMachine; \ No newline at end of file diff --git a/packages/xstate/src/machines/newEntry.js b/packages/xstate/src/machines/newEntry.js new file mode 100644 index 0000000..337a627 --- /dev/null +++ b/packages/xstate/src/machines/newEntry.js @@ -0,0 +1,130 @@ +import { setup, fromPromise, fromCallback, assign, sendTo } from 'xstate'; +import { createEntry } from '@timo/common/api'; + +const newEntryMachine = setup({ + actors: { + createEntry: fromPromise(async ({ input }) => createEntry(input)), + timer: fromCallback(({ sendBack, receive}) => { + const interval = setInterval(() => { + sendBack({ + type: 'tick' + }); + }, 1000); + receive((event) => { + if (event.type === 'stop') { + clearInterval(interval); + } + }); + }) + } +}).createMachine({ + id: 'newEntry', + initial: 'idle', + context: { + timerValue: 0, + statusMessage: null + }, + states: { + 'idle': { + entry: [ + assign({ + timerValue: 0 + }) + ], + on: { + 'start': { + target: 'active' + } + } + }, + 'active': { + entry: [ + assign({ + statusMessage: null + }) + ], + invoke: { + src: 'timer', + id: 'timer' + }, + on: { + 'tick': { + actions: assign({ + timerValue: ({ context }) => context.timerValue + 1 + }) + }, + 'pause': { + target: 'paused' + }, + 'finish': { + target: 'finishing' + } + }, + exit: [ + sendTo('timer', { + type: 'stop' + }) + ] + }, + 'paused': { + on: { + 'resume': { + target: 'active' + }, + 'finish': { + target: 'finishing' + } + } + }, + 'finishing': { + entry: [ + assign({ + statusMessage: 'Saving...' + }) + ], + invoke: { + src: 'createEntry', + input: ({ event, context }) => { + const endTimestamp = Math.floor(Date.now() / 1000); + return { + description: event.description, + start_time: `@${endTimestamp - context.timerValue}`, + end_time: `@${endTimestamp}` + }; + }, + onDone: { + target: 'finished' + }, + onError: { + target: 'paused', + actions: assign({ + statusMessage: ({ event }) => event.error.message + }) + } + } + }, + 'finished': { + entry: [ + assign({ + statusMessage: 'Time logged successfully' + }), + sendTo( + ({ system }) => system.get('entries'), + { + type: 'refresh' + } + ) + ], + after: { + 2000: { + target: 'idle', + actions: assign({ + statusMessage: null + }) + } + } + } + } +}); + +export default newEntryMachine; \ No newline at end of file diff --git a/packages/xstate/src/machines/profile.js b/packages/xstate/src/machines/profile.js new file mode 100644 index 0000000..0ab889c --- /dev/null +++ b/packages/xstate/src/machines/profile.js @@ -0,0 +1,37 @@ +import { logout } from '@timo/common/api'; +import { fromPromise, sendTo, setup } from 'xstate'; + +const profileMachine = setup({ + actors: { + logout: fromPromise(logout) + } +}).createMachine({ + id: 'profile', + initial: 'idle', + states: { + 'idle': { + on: { + 'logout': { + target: 'logging-out' + } + } + }, + 'logging-out': { + invoke: { + src: 'logout', + onDone: { + target: 'idle', + actions: sendTo( + ({ system }) => system.get('root'), + { type: 'unauthenticate' } + ) + }, + onError: { + target: 'idle' + } + } + } + } +}); + +export default profileMachine; \ No newline at end of file diff --git a/packages/xstate/src/machines/root.js b/packages/xstate/src/machines/root.js new file mode 100644 index 0000000..e91831a --- /dev/null +++ b/packages/xstate/src/machines/root.js @@ -0,0 +1,195 @@ +import { setup, spawnChild, sendTo, assign, fromPromise, fromCallback } from 'xstate'; +import history from 'history/browser'; +import { getUser } from '@timo/common/api'; +import customizeUserMachine from './customizeUser'; +import loginMachine from './login'; +import changePasswordMachine from './changePassword'; +import profileMachine from './profile'; +import entriesMachine from './entries'; +import newEntryMachine from './newEntry'; + +const BASE_URL = import.meta.env.VITE_BASE_URL; + +const routes = { + login: `${BASE_URL}/login`, + entries: `${BASE_URL}/`, + newEntry: `${BASE_URL}/new`, + profile: `${BASE_URL}/profile` +}; + +const rootMachine = setup({ + actors: { + getUser: fromPromise(getUser), + history: fromCallback(({ sendBack, receive }) => { + history.listen(({ location }) => { + sendBack({ + type: 'locationChanged', + location + }); + }); + receive((event) => { + if (event.type === 'getLocation') { + sendBack({ + type: 'locationChanged', + location: history.location + }); + } + if (event.type === 'pushLocation') { + history.push(event.location); + } + if (event.type === 'replaceLocation') { + history.push(event.location); + } + }); + }), + customizeUser: customizeUserMachine, + login: loginMachine, + changePassword: changePasswordMachine, + profile: profileMachine, + entries: entriesMachine, + newEntry: newEntryMachine + }, + guards: { + isNewEntryRoute: ({ event }) => { + return event.location.pathname === routes.newEntry; + }, + isProfileRoute: ({ event }) => { + return event.location.pathname === routes.profile; + } + } +}).createMachine({ + id: 'root', + entry: [ + spawnChild('history', { id: 'history' }) + ], + initial: 'unknown', + context: { + userData: null + }, + states: { + 'unknown': { + invoke: { + src: 'getUser', + onDone: { + target: 'authenticated', + actions: assign({ + userData: ({ event }) => ({ + id: event.output.id, + username: event.output.username, + avatar_character: event.output.avatar_character, + avatar_background: event.output.avatar_background + }) + }) + }, + onError: { + target: 'unauthenticated' + } + } + }, + 'authenticated': { + invoke: { + // Invoke entries here so that it doesn't reload as we navigate across routes + src: 'entries', + id: 'entries', + systemId: 'entries' + }, + initial: 'unknown', + states: { + 'unknown': { + entry: [ + sendTo('history', { type: 'getLocation' }) + ] + }, + 'entries': {}, + 'newEntry': { + invoke: { + src: 'newEntry', + id: 'newEntry' + } + }, + 'profile': { + invoke: [ + { + src: 'profile', + id: 'profile' + }, + { + src: 'customizeUser', + id: 'customizeUser', + input: ({ context }) => ({ + userId: context.userData.id, + username: context.userData.username, + avatar_character: context.userData.avatar_character, + avatar_background: context.userData.avatar_background + }) + }, + { + src: 'changePassword', + id: 'changePassword', + input: ({ context }) => ({ + username: context.userData.username + }) + } + ] + } + }, + on: { + 'locationChanged': [ + { + target: '.newEntry', + guard: 'isNewEntryRoute' + }, + { + target: '.profile', + guard: 'isProfileRoute' + }, + // Fallback to entries route if non specified or not found + { + target: '.entries' + } + ], + 'pushRoute': { + actions: sendTo('history', ({ event }) => ({ + type: 'pushLocation', + location: routes[event.route] + })) + }, + 'unauthenticate': { + target: 'unauthenticated' + } + } + }, + 'unauthenticated': { + initial: 'login', + states: { + 'login': { + entry: [ + sendTo('history', { + type: 'replaceLocation', + location: routes.login + }) + ], + invoke: { + src: loginMachine, + id: 'login' + } + } + }, + on: { + authenticate: { + target: 'authenticated', + actions: assign({ + userData:({ event }) => ({ + id: event.params.id, + username: event.params.username, + avatar_character: event.params.avatar_character, + avatar_background: event.params.avatar_background + }) + }) + } + } + } + } +}); + +export default rootMachine; diff --git a/packages/xstate/src/routes/Entries/Entries.jsx b/packages/xstate/src/routes/Entries/Entries.jsx index f4116d5..ef17b55 100644 --- a/packages/xstate/src/routes/Entries/Entries.jsx +++ b/packages/xstate/src/routes/Entries/Entries.jsx @@ -1,109 +1,57 @@ -import { useEffect, useState, createRef } from 'react'; -import PropTypes from 'prop-types'; -import { listEntries, updateEntry, deleteEntry } from '@timo/common/api'; import Entry from '@timo/common/components/Entry'; import Title from '@timo/common/components/Title'; import Input from '@timo/common/components/Input'; import Button from '@timo/common/components/Button'; import StatusMessage from '@timo/common/components/StatusMessage'; import formatDuration from '@timo/common/utils/formatDuration'; -import getDateString from '@timo/common/utils/getDateString'; import styles from './Entries.module.css'; import { ButtonVariants } from '@timo/common/components/Button/Button'; +import useChildMachineState from '../../hooks/useChildMachineState'; +import useChildMachine from '../../hooks/useChildMachine'; +import useRootMachine from '../../hooks/useRootMachine'; -const getTotalDuration = (entries) => { - return entries.reduce((total, entry) => { - const diff = new Date(entry.end_time) - new Date(entry.start_time); - return total + diff; - }, 0); -}; - -const getEntriesGroupedByDate = (entries) => { - const formatter = new Intl.DateTimeFormat('default', { dateStyle: 'medium' }); - // Entries are sorted by id which is ascending, so we need to reverse them - return entries.toReversed().reduce((grouped, entry) => { - const date = formatter.format(new Date(entry.start_time)); - if (!grouped[date]) { - grouped[date] = []; - } - grouped[date].push(entry); - return grouped; - }, {}); -}; - -const now = new Date(); -const firstDateOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); -const lastDateOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59); - -const Entries = ({ history }) => { - const [entries, setEntries] = useState(null); - const [statusMessage, setStatusMessage] = useState(null); - const [entryStatusMessage, setEntryStatusMessage] = useState({ id: null, message: null }); - const formRef = createRef(null); +const Entries = () => { + const { + groupedEntries, + totalDuration, + statusMessage, + filter, + itemStatusMessage + } = useChildMachineState('entries', state => state.context); + const entriesMachine = useChildMachine('entries'); + const rootMachine = useRootMachine(); const handleEdit = (updatedEntry) => { - setEntryStatusMessage({ id: updatedEntry.id, message: 'Saving...' }); - updateEntry(updatedEntry).then(() => { - setEntries(null); - setEntryStatusMessage({ id: null, message: null }); - }).catch((error) => { - setEntryStatusMessage({ id: updatedEntry.id, message: error.message }); + entriesMachine.send({ + type: 'updateEntry', + updatedEntry }); }; const handleDelete = (entryId) => { - setEntryStatusMessage({ id: entryId, message: 'Deleting...' }); - deleteEntry(entryId).then(() => { - setEntries(null); - setEntryStatusMessage({ id: null, message: null }); - }).catch((error) => { - setEntryStatusMessage({ id: entryId, message: error.message }); + entriesMachine.send({ + type: 'deleteEntry', + entryId }); }; - const handleListEntriesResponse = (entries) => { - setEntries(entries); - if (entries.length === 0) { - setStatusMessage('No entries found'); - } else { - setStatusMessage(null); - } - }; - - const handleListEntriesError = (error) => { - setStatusMessage(error.message); - }; - const handleFilter = (e) => { e.preventDefault(); const formData = new FormData(e.target); - const from = formData.get('from'); - const to = formData.get('to'); - setStatusMessage('Loading...'); - listEntries({ from, to }) - .then(handleListEntriesResponse) - .catch(handleListEntriesError); + entriesMachine.send({ + type: 'filter', + startDate: formData.get('from'), + endDate: formData.get('to') + }); }; const handleNewClick = () => { - history.push('./new'); + rootMachine.send({ + type: 'pushRoute', + route: 'newEntry' + }); }; - useEffect(() => { - const formData = new FormData(formRef.current); - const from = formData.get('from'); - const to = formData.get('to'); - - if (entries === null) { - setStatusMessage('Loading...'); - listEntries({ - from, - to - }).then(handleListEntriesResponse) - .catch(handleListEntriesError); - } - }, [entries]); - return ( <> Time entries @@ -111,26 +59,25 @@ const Entries = ({ history }) => {
-
- - + + +
{statusMessage && } - {entries?.length > 0 && ( + {totalDuration > 0 && ( <>

Total

-
{formatDuration(getTotalDuration(entries))}
+
{formatDuration(totalDuration)}
- {Object.entries(getEntriesGroupedByDate(entries)).map(([date, dayEntries]) => ( + {groupedEntries.map(([date, dayEntries, dayEntriesDuration]) => (

{date}

-
{formatDuration(getTotalDuration(dayEntries))}
+
{formatDuration(dayEntriesDuration)}
- {/* Entries are descending after being grouped, reverse them */} - {dayEntries.toReversed().map((entry) => ( + {dayEntries.map((entry) => ( { end_time={entry.end_time} onEdit={handleEdit} onDelete={handleDelete} - status={entryStatusMessage.id === entry.id ? entryStatusMessage.message : null} + status={itemStatusMessage[entry.id]} /> ))}
@@ -151,8 +98,4 @@ const Entries = ({ history }) => { ); }; -Entries.propTypes = { - history: PropTypes.object.isRequired -}; - export default Entries; diff --git a/packages/xstate/src/routes/Login/Login.jsx b/packages/xstate/src/routes/Login/Login.jsx index 9113a93..bfdafd0 100644 --- a/packages/xstate/src/routes/Login/Login.jsx +++ b/packages/xstate/src/routes/Login/Login.jsx @@ -1,22 +1,15 @@ -import { useState, useEffect } from 'react'; -import PropTypes from 'prop-types'; -import { login, register } from '@timo/common/api'; -import useUser from '@timo/common/hooks/useUser'; import Input from '@timo/common/components/Input'; import Button, { ButtonVariants } from '@timo/common/components/Button'; import Title from '@timo/common/components/Title'; import StatusMessage from '@timo/common/components/StatusMessage'; -import styles from './Login.module.css'; +import useChildMachine from '../../hooks/useChildMachine'; +import useChildMachineState from '../../hooks/useChildMachineState'; -const Login = ({ history }) => { - const user = useUser(); - const [statusMessage, setStatusMessage] = useState(null); +import styles from './Login.module.css'; - useEffect(() => { - if (user.status === 'authenticated') { - history.replace('./'); - } - }, [history, user]); +const Login = () => { + const statusMessage = useChildMachineState('login', (state) => state.context.statusMessage); + const loginMachine = useChildMachine('login'); const handleFormSubmit = (e) => { e.preventDefault(); @@ -26,23 +19,19 @@ const Login = ({ history }) => { const username = formData.get('username'); const password = formData.get('password'); - setStatusMessage('Loading...'); - if (action == 'login') { - login({ username, password }).then((response) => { - user.setAuthenticatedUser(response); - history.replace('./'); - }).catch((error) => { - setStatusMessage(error.message); + loginMachine.send({ + type: 'login', + username, + password }); } if (action == 'register') { - register({ username, password }).then((response) => { - user.setAuthenticatedUser(response); - history.replace('./'); - }).catch((error) => { - setStatusMessage(error.message); + loginMachine.send({ + type: 'register', + username, + password }); } }; @@ -63,10 +52,4 @@ const Login = ({ history }) => { ); }; -Login.propTypes = { - history: PropTypes.shape({ - replace: PropTypes.func.isRequired - }).isRequired -}; - export default Login; \ No newline at end of file diff --git a/packages/xstate/src/routes/NewEntry/NewEntry.jsx b/packages/xstate/src/routes/NewEntry/NewEntry.jsx index 27da0d6..548b2be 100644 --- a/packages/xstate/src/routes/NewEntry/NewEntry.jsx +++ b/packages/xstate/src/routes/NewEntry/NewEntry.jsx @@ -1,110 +1,76 @@ -import { useEffect, useState } from 'react'; -import { createEntry } from '@timo/common/api'; -import Timer from '@timo/common/components/Timer'; import Title from '@timo/common/components/Title'; import Input from '@timo/common/components/Input'; import Button, { ButtonVariants } from '@timo/common/components/Button'; import StatusMessage from '@timo/common/components/StatusMessage'; +import useChildMachine from '../../hooks/useChildMachine'; +import useChildMachineState from '../../hooks/useChildMachineState'; +import Timer from '../../components/Timer'; import styles from './NewEntry.module.css'; -const TimerState = { - ACTIVE: 'active', - PAUSED: 'paused', - STOPPED: 'stopped' -}; - const NewEntry = () => { - const [duration, setDuration] = useState(0); - const [timerState, setTimerState] = useState(TimerState.STOPPED); - const [description, setDescription] = useState(''); - const [statusMessage, setStatusMessage] = useState(''); - - const handleStartClick = () => { - if (description.length === 0) { - setStatusMessage('Description is required'); - } else { - setTimerState(TimerState.ACTIVE); - setStatusMessage(''); - } - }; - - const handleStopClick = () => { - setTimerState(TimerState.STOPPED); - }; - - const handlePauseClick = () => { - setTimerState(TimerState.PAUSED); - }; + const timerState = useChildMachineState('newEntry', state => state.value); + const statusMessage = useChildMachineState('newEntry', state => state.context.statusMessage); + const newEntryMachine = useChildMachine('newEntry'); - const handleDescriptionChange = (e) => { - setDescription(e.target.value); + const handleSubmit = (e) => { + e.preventDefault(); + const action = e.nativeEvent.submitter.value; + const formData = new FormData(e.target); + newEntryMachine.send({ + type: action, + description: formData.get('description') + }); }; - useEffect(() => { - if (timerState === TimerState.STOPPED && duration > 0) { - setDuration(0); - const endTimestamp = Math.floor(Date.now() / 1000); - createEntry({ - description, - start_time: `@${endTimestamp - duration}`, - end_time: `@${endTimestamp}` - }).then(() => { - setStatusMessage('Time logged successfully'); - }).catch((error) => { - setStatusMessage(error.message); - }); - } - }); - return ( <> New time entry -
- - -
- -
- {(timerState === TimerState.STOPPED && duration === 0) && ( - +
+
+ + +
+ {statusMessage && ( + )} - {(timerState === TimerState.PAUSED && duration !== 0) && ( - <> - - - - )} - {timerState === TimerState.ACTIVE && ( - <> - - - - )} -
+ )} + {timerState === 'paused' && ( + <> + + + + )} + {timerState === 'active' && ( + <> + + + + )} + + ); }; diff --git a/packages/xstate/src/routes/NewEntry/NewEntry.module.css b/packages/xstate/src/routes/NewEntry/NewEntry.module.css index 8a010a6..8083a78 100644 --- a/packages/xstate/src/routes/NewEntry/NewEntry.module.css +++ b/packages/xstate/src/routes/NewEntry/NewEntry.module.css @@ -21,6 +21,7 @@ justify-content: center; align-items: center; gap: 8px; + margin-top: 12px; } .entries { diff --git a/packages/xstate/src/routes/Profile/Profile.jsx b/packages/xstate/src/routes/Profile/Profile.jsx index 747d93e..d893967 100644 --- a/packages/xstate/src/routes/Profile/Profile.jsx +++ b/packages/xstate/src/routes/Profile/Profile.jsx @@ -1,18 +1,15 @@ import Title from '@timo/common/components/Title'; import Button, { ButtonVariants } from '@timo/common/components/Button'; -import useUser from '@timo/common/hooks/useUser'; -import { logout } from '@timo/common/api'; import styles from './Profile.module.css'; import ChangePassword from './sections/ChangePassword'; import CustomizeUser from './sections/CustomizeUser'; +import useChildMachine from '../../hooks/useChildMachine'; const Profile = () => { - const user = useUser(); + const profileMachine = useChildMachine('profile'); const handleLogoutClick = () => { - logout().then(() => { - user.clearUser(); - }); + profileMachine.send({ type: 'logout' }); }; return ( diff --git a/packages/xstate/src/routes/Profile/sections/ChangePassword.jsx b/packages/xstate/src/routes/Profile/sections/ChangePassword.jsx index 54b25aa..7d2684f 100644 --- a/packages/xstate/src/routes/Profile/sections/ChangePassword.jsx +++ b/packages/xstate/src/routes/Profile/sections/ChangePassword.jsx @@ -1,32 +1,21 @@ -import { useState } from 'react'; import Input from '@timo/common/components/Input'; -import useUser from '@timo/common/hooks/useUser'; import StatusMessage from '@timo/common/components/StatusMessage'; import Button from '@timo/common/components/Button'; -import { updatePassword } from '@timo/common/api'; import styles from '../Profile.module.css'; +import useChildMachine from '../../../hooks/useChildMachine'; +import useChildMachineState from '../../../hooks/useChildMachineState'; const ChangePassword = () => { - const user = useUser(); - const [passwordStatus, setPasswordStatus] = useState(null); + const { statusMessage } = useChildMachineState('changePassword', state => state.context); + const changePasswordMachine = useChildMachine('changePassword'); const handlePasswordFormSubmit = (e) => { e.preventDefault(); - const formData = new FormData(e.target); - const password = formData.get('password'); - const newPassword = formData.get('newPassword'); - - setPasswordStatus('Loading...'); - - updatePassword({ - username: user?.data?.username, - password, - newPassword - }).then(() => { - setPasswordStatus('Password updated'); - }).catch((error) => { - setPasswordStatus(error.message); + changePasswordMachine.send({ + type: 'save', + password: formData.get('password'), + newPassword: formData.get('newPassword') }); }; @@ -50,7 +39,7 @@ const ChangePassword = () => { labelVisible required /> - {passwordStatus && } + {statusMessage && }
diff --git a/packages/xstate/src/routes/Profile/sections/CustomizeUser.jsx b/packages/xstate/src/routes/Profile/sections/CustomizeUser.jsx index 386e541..b4413e7 100644 --- a/packages/xstate/src/routes/Profile/sections/CustomizeUser.jsx +++ b/packages/xstate/src/routes/Profile/sections/CustomizeUser.jsx @@ -1,64 +1,44 @@ -import { useEffect, useState } from 'react'; import Avatar from '@timo/common/components/Avatar'; import RadioGroup from '@timo/common/components/RadioGroup'; import Input from '@timo/common/components/Input'; -import useUser from '@timo/common/hooks/useUser'; import StatusMessage from '@timo/common/components/StatusMessage'; import Button from '@timo/common/components/Button'; -import { updateUser } from '@timo/common/api'; +import useChildMachine from '../../../hooks/useChildMachine'; +import useChildMachineState from '../../../hooks/useChildMachineState'; import styles from '../Profile.module.css'; const CustomizeUser = () => { - const user = useUser(); - const [customizeStatus, setCustomizeStatus] = useState(null); - const [avatar, setAvatar] = useState({ - character: undefined, - background: undefined - }); - - useEffect(() => { - if (user?.data) { - setAvatar({ - character: user.data.avatar_character, - background: user.data.avatar_background - }); - } - }, [user]); + const customizeUserMachine = useChildMachine('customizeUser'); + const { + username, + avatar_character, + avatar_background, + statusMessage + } = useChildMachineState('customizeUser', state => state.context); const handleCustomizeFormSubmit = (e) => { e.preventDefault(); - const formData = new FormData(e.target); - const username = formData.get('username'); - const avatarCharacter = formData.get('avatar-character'); - const avatarBackground = formData.get('avatar-background'); - - setCustomizeStatus('Loading...'); - - updateUser({ - id: user?.data?.id, - username, - avatar_character: avatarCharacter, - avatar_background: avatarBackground - }).then(() => { - setCustomizeStatus('Profile updated'); - // Clear the user in context and force refetch - user.clearUser(); - }).catch((error) => { - setCustomizeStatus(error.message); - }); + customizeUserMachine.send({ type: 'save' }); }; const handleAvatarBackgroundChange = (e) => { - setAvatar({ - ...avatar, - background: e.target.value + customizeUserMachine.send({ + type: 'changeAvatarBackground', + value: e.target.value }); }; const handleAvatarCharacterChange = (e) => { - setAvatar({ - ...avatar, - character: e.target.value + customizeUserMachine.send({ + type: 'changeAvatarCharacter', + value: e.target.value + }); + }; + + const handleUsernameChange = (e) => { + customizeUserMachine.send({ + type: 'changeUsername', + value: e.target.value }); }; @@ -66,8 +46,8 @@ const CustomizeUser = () => { <>
@@ -80,7 +60,7 @@ const CustomizeUser = () => { { value: 'light', label: 'Light' }, { value: 'dark', label: 'Dark' } ]} - defaultValue={user?.data?.avatar_background} + defaultValue={avatar_background} onChange={handleAvatarBackgroundChange} /> { type="text" maxLength={1} pattern="[A-Za-z]" - defaultValue={user?.data?.avatar_character} + defaultValue={avatar_character} onChange={handleAvatarCharacterChange} labelVisible required @@ -98,11 +78,12 @@ const CustomizeUser = () => { name="username" label="Username" type="text" - defaultValue={user?.data?.username} + defaultValue={username} + onChange={handleUsernameChange} labelVisible required /> - {customizeStatus && } + {statusMessage && }
diff --git a/packages/zustand/src/App.jsx b/packages/zustand/src/App.jsx index 43b388f..9ed5ec0 100644 --- a/packages/zustand/src/App.jsx +++ b/packages/zustand/src/App.jsx @@ -16,7 +16,6 @@ import ProtectedRoute from './contextualComponents/ProtectedRoute'; const routes = [ { path: '/', name: 'Entries' }, { path: '/login', name: 'Login' }, - { path: '/register', name: 'Register' }, { path: '/new', name: 'NewEntry' }, { path: '/profile', name: 'Profile' } ]; diff --git a/prepare-deploy.js b/prepare-deploy.js index 5afd57e..c65eb8c 100644 --- a/prepare-deploy.js +++ b/prepare-deploy.js @@ -15,4 +15,7 @@ fs.readdirSync(source, { withFileTypes: true }).forEach(dirent => { fs.cpSync(distPath, outputPath, { recursive: true }); } } -}); \ No newline at end of file +}); + +const public = path.join(__dirname, 'public'); +fs.cpSync(public, destination, { recursive: true }); \ No newline at end of file diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..77470cb --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / \ No newline at end of file