├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── 1-feature-request.yml │ └── 2-bug-report.yml ├── config.yml ├── dependabot.yml ├── stale.yml └── workflows │ └── test.yml ├── .gitignore ├── .husky ├── pre-commit └── pre-push ├── .prettierignore ├── .prettierrc ├── AGENTS.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── babel.config.js ├── eslint.config.mjs ├── jest.config.js ├── lerna.json ├── package.json ├── packages ├── backend │ ├── .env.example │ ├── globalConfig.json │ ├── jest-mongodb-config.js │ ├── package.json │ ├── src │ │ ├── __tests__ │ │ │ ├── backend.test.init.js │ │ │ ├── backend.test.start.js │ │ │ ├── helpers │ │ │ │ ├── mock.db.queries.ts │ │ │ │ ├── mock.db.setup.ts │ │ │ │ └── mock.events.init.ts │ │ │ ├── mocks.db │ │ │ │ └── ccal.mock.db.util.ts │ │ │ └── mocks.gcal │ │ │ │ ├── errors │ │ │ │ ├── error.google.invalidGrant.ts │ │ │ │ ├── error.google.invalidValue.ts │ │ │ │ └── error.invalidSyncToken.ts │ │ │ │ ├── factories │ │ │ │ ├── gcal.event.batch.ts │ │ │ │ ├── gcal.event.factory.test.ts │ │ │ │ ├── gcal.event.factory.ts │ │ │ │ └── gcal.factory.ts │ │ │ │ ├── fixtures │ │ │ │ └── recurring │ │ │ │ │ ├── create │ │ │ │ │ └── create.ts │ │ │ │ │ ├── delete │ │ │ │ │ ├── all │ │ │ │ │ │ ├── all-1.ts │ │ │ │ │ │ ├── all-2.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── single │ │ │ │ │ │ ├── delete.single-2.ts │ │ │ │ │ │ ├── delete.single.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── this-and-following │ │ │ │ │ │ ├── delete.this-and-following-2.ts │ │ │ │ │ │ ├── delete.this-and-following.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── edit │ │ │ │ │ ├── all │ │ │ │ │ ├── all-1.ts │ │ │ │ │ ├── all-2.ts │ │ │ │ │ └── index.ts │ │ │ │ │ ├── single │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── single.instance-1.ts │ │ │ │ │ ├── single.instance-2.ts │ │ │ │ │ └── single.instance-3.ts │ │ │ │ │ ├── this-and-following-v2 │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── this-and-following-0.ts │ │ │ │ │ └── this-and-following-1.ts │ │ │ │ │ └── this-and-following │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── this-and-following-1.ts │ │ │ │ │ ├── this-and-following-2.ts │ │ │ │ │ ├── this-and-following-3.ts │ │ │ │ │ └── this-and-following-4.ts │ │ │ │ └── mocks.gcal │ │ │ │ ├── errors │ │ │ │ ├── error.google.invalidGrant.ts │ │ │ │ ├── error.google.invalidValue.ts │ │ │ │ └── error.invalidSyncToken.ts │ │ │ │ ├── factories │ │ │ │ ├── gcal.event.factory.test.ts │ │ │ │ ├── gcal.event.factory.ts │ │ │ │ └── gcal.factory.ts │ │ │ │ └── fixtures │ │ │ │ └── recurring │ │ │ │ ├── create │ │ │ │ └── create.ts │ │ │ │ ├── delete │ │ │ │ ├── all │ │ │ │ │ ├── all-1.ts │ │ │ │ │ ├── all-2.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── single │ │ │ │ │ ├── delete.single-2.ts │ │ │ │ │ ├── delete.single.ts │ │ │ │ │ └── index.ts │ │ │ │ └── this-and-following │ │ │ │ │ ├── delete.this-and-following-2.ts │ │ │ │ │ ├── delete.this-and-following.ts │ │ │ │ │ └── index.ts │ │ │ │ └── edit │ │ │ │ ├── all │ │ │ │ ├── all-1.ts │ │ │ │ ├── all-2.ts │ │ │ │ └── index.ts │ │ │ │ ├── single │ │ │ │ ├── index.ts │ │ │ │ ├── single.instance-1.ts │ │ │ │ ├── single.instance-2.ts │ │ │ │ ├── single.instance-3.ts │ │ │ │ └── single.instance-4.ts │ │ │ │ └── this-and-following │ │ │ │ ├── batch1 │ │ │ │ ├── index.ts │ │ │ │ ├── this-and-following-v2-0.ts │ │ │ │ ├── this-and-following-v2-1.ts │ │ │ │ └── this-and-following-v2-2.ts │ │ │ │ ├── batch2 │ │ │ │ ├── index.ts │ │ │ │ ├── this-and-following-1.ts │ │ │ │ ├── this-and-following-2.ts │ │ │ │ ├── this-and-following-3.ts │ │ │ │ └── this-and-following-4.ts │ │ │ │ └── index.ts │ │ ├── app.ts │ │ ├── auth │ │ │ ├── auth.routes.config.ts │ │ │ ├── controllers │ │ │ │ ├── auth.controller.test.ts │ │ │ │ └── auth.controller.ts │ │ │ ├── middleware │ │ │ │ └── auth.middleware.ts │ │ │ └── services │ │ │ │ ├── auth.utils.ts │ │ │ │ ├── compass.auth.service.ts │ │ │ │ └── google.auth.service.ts │ │ ├── calendar │ │ │ ├── calendar.routes.config.ts │ │ │ ├── controllers │ │ │ │ └── calendar.controller.ts │ │ │ └── services │ │ │ │ └── calendar.service.ts │ │ ├── common │ │ │ ├── common.routes.config.ts │ │ │ ├── constants │ │ │ │ ├── backend.constants.ts │ │ │ │ ├── collections.ts │ │ │ │ ├── env.constants.ts │ │ │ │ └── env.util.ts │ │ │ ├── errors │ │ │ │ ├── auth │ │ │ │ │ └── auth.errors.ts │ │ │ │ ├── db │ │ │ │ │ └── db.errors.ts │ │ │ │ ├── emailer │ │ │ │ │ └── emailer.errors.ts │ │ │ │ ├── event │ │ │ │ │ └── event.errors.ts │ │ │ │ ├── generic │ │ │ │ │ └── generic.errors.ts │ │ │ │ ├── handlers │ │ │ │ │ ├── error.express.handler.ts │ │ │ │ │ ├── error.handler.ts │ │ │ │ │ └── error.unexpected.handler.ts │ │ │ │ ├── integration │ │ │ │ │ └── gcal │ │ │ │ │ │ └── gcal.errors.ts │ │ │ │ ├── socket │ │ │ │ │ └── socket.errors.ts │ │ │ │ ├── sync │ │ │ │ │ └── sync.errors.ts │ │ │ │ ├── user │ │ │ │ │ └── user.errors.ts │ │ │ │ └── waitlist │ │ │ │ │ └── waitlist.errors.ts │ │ │ ├── helpers │ │ │ │ ├── common.util.test.ts │ │ │ │ ├── common.util.ts │ │ │ │ └── mongo.utils.ts │ │ │ ├── middleware │ │ │ │ ├── cors.middleware.ts │ │ │ │ ├── http.logger.middleware.ts │ │ │ │ ├── mongo.validation.middleware.ts │ │ │ │ ├── promise.middleware.ts │ │ │ │ └── supertokens.middleware.ts │ │ │ ├── services │ │ │ │ ├── gcal │ │ │ │ │ ├── gcal.service.ts │ │ │ │ │ ├── gcal.util.test.ts │ │ │ │ │ └── gcal.utils.ts │ │ │ │ └── mongo.service.ts │ │ │ ├── types │ │ │ │ ├── backend.event.types.ts │ │ │ │ ├── error.types.ts │ │ │ │ ├── express.types.ts │ │ │ │ └── sync.types.ts │ │ │ └── validators │ │ │ │ ├── validate.event.ts │ │ │ │ └── validate.ts │ │ ├── email │ │ │ ├── email.service.ts │ │ │ └── email.types.ts │ │ ├── event │ │ │ ├── controllers │ │ │ │ └── event.controller.ts │ │ │ ├── event.routes.config.ts │ │ │ ├── queries │ │ │ │ └── event.queries.ts │ │ │ └── services │ │ │ │ ├── event.delete.test.ts │ │ │ │ ├── event.find.allday.test.ts │ │ │ │ ├── event.find.test.ts │ │ │ │ ├── event.service.ts │ │ │ │ ├── event.service.util.ts │ │ │ │ └── recur │ │ │ │ ├── parse │ │ │ │ ├── recur.gcal.parse.test.ts │ │ │ │ └── recur.gcal.parse.ts │ │ │ │ ├── queries │ │ │ │ └── event.recur.queries.ts │ │ │ │ ├── recur.general.test.ts │ │ │ │ ├── recur.manager.ts │ │ │ │ ├── recur.month.test.ts │ │ │ │ ├── recur.test.util.ts │ │ │ │ ├── recur.types.ts │ │ │ │ ├── recur.week.test.ts │ │ │ │ ├── repo │ │ │ │ ├── event.repo.ts │ │ │ │ ├── recur.event.repo.ts │ │ │ │ └── recur.event.repo.util.ts │ │ │ │ └── util │ │ │ │ ├── recur.gcal.util.ts │ │ │ │ ├── recur.util.test.ts │ │ │ │ └── recur.util.ts │ │ ├── init.ts │ │ ├── priority │ │ │ ├── controllers │ │ │ │ └── priority.controller.ts │ │ │ ├── middleware │ │ │ │ └── priority.middleware.ts │ │ │ ├── priority.routes.config.ts │ │ │ └── services │ │ │ │ ├── priority.service.helpers.test.ts │ │ │ │ ├── priority.service.helpers.ts │ │ │ │ └── priority.service.ts │ │ ├── servers │ │ │ ├── express │ │ │ │ └── express.server.ts │ │ │ └── websocket │ │ │ │ ├── websocket.server.errors.test.ts │ │ │ │ ├── websocket.server.test.ts │ │ │ │ ├── websocket.server.ts │ │ │ │ ├── websocket.util.test.ts │ │ │ │ └── websocket.util.ts │ │ ├── sync │ │ │ ├── controllers │ │ │ │ ├── sync.controller.ts │ │ │ │ └── sync.debug.controller.ts │ │ │ ├── services │ │ │ │ ├── import │ │ │ │ │ ├── sync.import.full.test.ts │ │ │ │ │ ├── sync.import.series.test.ts │ │ │ │ │ ├── sync.import.ts │ │ │ │ │ ├── sync.import.types.ts │ │ │ │ │ ├── sync.import.util.test.ts │ │ │ │ │ └── sync.import.util.ts │ │ │ │ ├── init │ │ │ │ │ └── sync.init.ts │ │ │ │ ├── log │ │ │ │ │ └── sync.logger.ts │ │ │ │ ├── maintain │ │ │ │ │ └── sync.maintenance.ts │ │ │ │ ├── notify │ │ │ │ │ ├── gcal.notification.util.ts │ │ │ │ │ └── handler │ │ │ │ │ │ ├── gcal.notification.handler.test.ts │ │ │ │ │ │ └── gcal.notification.handler.ts │ │ │ │ ├── sync.service.ts │ │ │ │ ├── sync │ │ │ │ │ ├── __tests__ │ │ │ │ │ │ ├── gcal.sync.processor.delete.test.ts │ │ │ │ │ │ ├── gcal.sync.processor.delete.util.ts │ │ │ │ │ │ ├── gcal.sync.processor.test.util.ts │ │ │ │ │ │ ├── gcal.sync.processor.upsert.base.split.test.ts │ │ │ │ │ │ ├── gcal.sync.processor.upsert.base.test.ts │ │ │ │ │ │ ├── gcal.sync.processor.upsert.instance.test.ts │ │ │ │ │ │ └── gcal.sync.processor.upsert.standalone.test.ts │ │ │ │ │ └── gcal.sync.processor.ts │ │ │ │ └── watch │ │ │ │ │ └── sync.watch.ts │ │ │ ├── sync.routes.config.ts │ │ │ ├── sync.types.ts │ │ │ └── util │ │ │ │ ├── sync.queries.ts │ │ │ │ └── sync.util.ts │ │ ├── user │ │ │ ├── controllers │ │ │ │ └── user.controller.ts │ │ │ ├── queries │ │ │ │ └── user.queries.ts │ │ │ ├── services │ │ │ │ └── user.service.ts │ │ │ ├── types │ │ │ │ └── user.types.ts │ │ │ └── user.routes.config.ts │ │ └── waitlist │ │ │ ├── controller │ │ │ ├── waitlist.controller-add.test.ts │ │ │ ├── waitlist.controller-check.test.ts │ │ │ └── waitlist.controller.ts │ │ │ ├── repo │ │ │ └── waitlist.repo.ts │ │ │ ├── service │ │ │ ├── waitlist.service-add.test.ts │ │ │ ├── waitlist.service-check.test.ts │ │ │ ├── waitlist.service-getAllWaitlisted.test.ts │ │ │ ├── waitlist.service-invite.test.ts │ │ │ ├── waitlist.service.test-setup.ts │ │ │ └── waitlist.service.ts │ │ │ └── waitlist.routes.config.ts │ └── tsconfig.json ├── core │ ├── package.json │ ├── src │ │ ├── __mocks__ │ │ │ └── v1 │ │ │ │ ├── calendarlist │ │ │ │ ├── calendarlist.ts │ │ │ │ └── gcal.calendarlist.ts │ │ │ │ └── events │ │ │ │ ├── events.22jan.ts │ │ │ │ ├── events.22mar.ts │ │ │ │ ├── events.allday.1.ts │ │ │ │ ├── events.allday.2.ts │ │ │ │ ├── events.allday.3.ts │ │ │ │ ├── events.misc.ts │ │ │ │ ├── events.someday.1.ts │ │ │ │ ├── events.someday.recur.ts │ │ │ │ └── gcal │ │ │ │ ├── gcal.allday.ts │ │ │ │ ├── gcal.cancelled.ts │ │ │ │ ├── gcal.event.ts │ │ │ │ ├── gcal.recurring.ts │ │ │ │ └── gcal.timed.ts │ │ ├── constants │ │ │ ├── core.constants.ts │ │ │ ├── date.constants.ts │ │ │ └── websocket.constants.ts │ │ ├── errors │ │ │ ├── errors.base.ts │ │ │ └── status.codes.ts │ │ ├── logger │ │ │ └── winston.logger.ts │ │ ├── mappers │ │ │ ├── map.calendarlist.test.ts │ │ │ ├── map.calendarlist.ts │ │ │ ├── map.event.to-compass.test.ts │ │ │ ├── map.event.to-gcal.test.ts │ │ │ ├── map.event.ts │ │ │ ├── map.user.test.ts │ │ │ ├── map.user.ts │ │ │ └── subscriber │ │ │ │ ├── map.subscriber.test.ts │ │ │ │ └── map.subscriber.ts │ │ ├── types.ts │ │ ├── types │ │ │ ├── auth.types.ts │ │ │ ├── calendar.types.ts │ │ │ ├── email │ │ │ │ └── email.types.ts │ │ │ ├── event.types.ts │ │ │ ├── gcal.d.ts │ │ │ ├── jwt.types.ts │ │ │ ├── mongo.types.ts │ │ │ ├── priority.types.ts │ │ │ ├── sync.types.ts │ │ │ ├── type.utils.ts │ │ │ ├── user.types.ts │ │ │ ├── waitlist │ │ │ │ ├── waitlist.answer.types.ts │ │ │ │ └── waitlist.types.ts │ │ │ └── websocket.types.ts │ │ ├── util │ │ │ ├── app.util.ts │ │ │ ├── color.utils.ts │ │ │ ├── date │ │ │ │ ├── date.util.test.ts │ │ │ │ └── date.util.ts │ │ │ ├── env.util.ts │ │ │ ├── event │ │ │ │ ├── event.util.test.ts │ │ │ │ ├── event.util.ts │ │ │ │ └── gcal.event.util.ts │ │ │ └── test │ │ │ │ └── ccal.event.factory.ts │ │ └── validators │ │ │ ├── event.validator.test.ts │ │ │ └── event.validator.ts │ └── tsconfig.json ├── scripts │ ├── package.json │ ├── src │ │ ├── cli.ts │ │ ├── cli.validator.ts │ │ ├── commands │ │ │ ├── build.ts │ │ │ ├── delete.ts │ │ │ ├── devWeb.js │ │ │ ├── invite.ts │ │ │ └── seed.ts │ │ ├── common │ │ │ ├── cli.constants.ts │ │ │ ├── cli.types.ts │ │ │ └── cli.utils.ts │ │ ├── init.ts │ │ └── yarn.lock │ └── tsconfig.json └── web │ ├── declaration.d.ts │ ├── package.json │ ├── src │ ├── App.tsx │ ├── __tests__ │ │ ├── Calendar │ │ │ ├── Sidebar │ │ │ │ ├── sidebar.interactions.test.tsx │ │ │ │ └── sidebar.render.test.tsx │ │ │ ├── calendar.interactions.test.tsx │ │ │ ├── calendar.render.test.tsx │ │ │ └── calendar.render.test.utils.ts │ │ ├── Login │ │ │ └── Login.wip.tsx │ │ ├── __mocks__ │ │ │ ├── css.stub.js │ │ │ ├── file.stub.js │ │ │ ├── mock.render.tsx │ │ │ ├── server │ │ │ │ ├── mock.handlers.ts │ │ │ │ └── mock.server.ts │ │ │ ├── state │ │ │ │ └── state.weekEvents.ts │ │ │ ├── svg.stub.js │ │ │ └── tabbable.js │ │ ├── utils │ │ │ ├── date.util │ │ │ │ ├── date.label.test.ts │ │ │ │ └── date.parse.test.ts │ │ │ ├── grid.util │ │ │ │ ├── assign.row.test.ts │ │ │ │ ├── columns │ │ │ │ │ ├── column.helpers.ts │ │ │ │ │ └── column.widths.test.ts │ │ │ │ └── grid.util.test.ts │ │ │ ├── test.util.ts │ │ │ └── web.utils.test.js │ │ ├── web.test.init.js │ │ └── web.test.start.js │ ├── assets │ │ └── png │ │ │ ├── derek.png │ │ │ └── notFound.png │ ├── auth │ │ ├── ProtectedRoute.tsx │ │ ├── UserContext.tsx │ │ ├── auth.util.ts │ │ └── useAuthCheck.ts │ ├── common │ │ ├── apis │ │ │ ├── auth.api.ts │ │ │ ├── calendarlist.api.ts │ │ │ ├── compass.api.ts │ │ │ ├── priority.api.ts │ │ │ ├── sync.api.ts │ │ │ └── waitlist.api.ts │ │ ├── constants │ │ │ ├── auth.constants.ts │ │ │ ├── env.constants.ts │ │ │ ├── routes.ts │ │ │ ├── storage.constants.ts │ │ │ └── web.constants.ts │ │ ├── store │ │ │ ├── helpers │ │ │ │ └── index.ts │ │ │ └── middlewares │ │ │ │ └── index.ts │ │ ├── styles │ │ │ ├── colors.ts │ │ │ ├── default-theme.d.ts │ │ │ ├── theme.ts │ │ │ └── theme.util.ts │ │ ├── types │ │ │ ├── api.types.ts │ │ │ ├── component.types.ts │ │ │ ├── dnd.types.ts │ │ │ ├── entity.types.ts │ │ │ ├── util.types.ts │ │ │ └── web.event.types.ts │ │ ├── utils │ │ │ ├── __tests__ │ │ │ │ └── someday.util.test.ts │ │ │ ├── datetime │ │ │ │ ├── web.datetime.util.test.ts │ │ │ │ └── web.datetime.util.ts │ │ │ ├── device.util.ts │ │ │ ├── draft │ │ │ │ ├── draft.util.test.ts │ │ │ │ ├── draft.util.ts │ │ │ │ ├── someday.draft.util.test.ts │ │ │ │ └── someday.draft.util.ts │ │ │ ├── event-target-visibility.util.ts │ │ │ ├── event.util.test.ts │ │ │ ├── event.util.ts │ │ │ ├── grid.util.test.ts │ │ │ ├── grid.util.ts │ │ │ ├── index.ts │ │ │ ├── mouse │ │ │ │ └── mouse.util.ts │ │ │ ├── overlap │ │ │ │ ├── overlap.test.ts │ │ │ │ └── overlap.ts │ │ │ ├── position.util.test.ts │ │ │ ├── position.util.ts │ │ │ ├── shortcut.util.tsx │ │ │ ├── someday.util.ts │ │ │ ├── web.date.util.test.ts │ │ │ └── web.date.util.ts │ │ └── validators │ │ │ ├── grid.event.validator.test.ts │ │ │ ├── grid.event.validator.ts │ │ │ └── someday.event.validator.ts │ ├── components │ │ ├── AbsoluteOverflowLoader │ │ │ ├── AbsoluteOverflowLoader.tsx │ │ │ ├── index.ts │ │ │ └── styled.ts │ │ ├── Button │ │ │ └── styled.ts │ │ ├── CheckBox │ │ │ ├── CheckBox.tsx │ │ │ ├── index.ts │ │ │ └── styled.ts │ │ ├── ContextMenu │ │ │ ├── ContextMenu.tsx │ │ │ ├── ContextMenuItems.tsx │ │ │ ├── GridContextMenuWrapper.tsx │ │ │ └── styled.ts │ │ ├── DatePicker │ │ │ ├── DatePicker.tsx │ │ │ └── styled.ts │ │ ├── Divider │ │ │ ├── Divider.tsx │ │ │ ├── index.ts │ │ │ ├── styled.ts │ │ │ └── types.ts │ │ ├── Flex │ │ │ ├── index.ts │ │ │ └── styled.ts │ │ ├── Focusable │ │ │ └── Focusable.tsx │ │ ├── GlobalStyle │ │ │ ├── index.ts │ │ │ └── styled.ts │ │ ├── IconButton │ │ │ ├── IconButton.tsx │ │ │ └── styled.ts │ │ ├── IconProvider │ │ │ └── IconProvider.tsx │ │ ├── Icons │ │ │ ├── Calendar.tsx │ │ │ ├── Command.tsx │ │ │ ├── List.tsx │ │ │ ├── Repeat.tsx │ │ │ ├── Sidebar.tsx │ │ │ ├── Todo.tsx │ │ │ ├── X.tsx │ │ │ ├── icon.types.ts │ │ │ └── styled.ts │ │ ├── Input │ │ │ ├── Input.tsx │ │ │ └── styled.ts │ │ ├── LoginAbsoluteOverflowLoader │ │ │ ├── LoginAbsoluteOverflowLoader.tsx │ │ │ └── styled.ts │ │ ├── SpaceCharacter │ │ │ ├── SpaceCharacter.tsx │ │ │ └── index.ts │ │ ├── Text │ │ │ ├── index.ts │ │ │ └── styled.ts │ │ ├── Textarea │ │ │ ├── Textarea.tsx │ │ │ ├── index.ts │ │ │ ├── styled.ts │ │ │ └── types.ts │ │ ├── Tooltip │ │ │ ├── Description │ │ │ │ ├── TooltipDescription.tsx │ │ │ │ └── index.ts │ │ │ ├── Tooltip.tsx │ │ │ ├── TooltipWrapper.tsx │ │ │ ├── index.ts │ │ │ ├── styled.ts │ │ │ ├── types.ts │ │ │ └── useTooltip.ts │ │ └── TooltipIconButton │ │ │ └── TooltipIconButton.tsx │ ├── ducks │ │ ├── events │ │ │ ├── context │ │ │ │ ├── sync.context.ts │ │ │ │ └── week.context.ts │ │ │ ├── event.api.ts │ │ │ ├── event.types.ts │ │ │ ├── sagas │ │ │ │ ├── event.sagas.ts │ │ │ │ ├── saga.util.ts │ │ │ │ └── someday.sagas.ts │ │ │ ├── selectors │ │ │ │ ├── draft.selectors.ts │ │ │ │ ├── event.selectors.ts │ │ │ │ ├── someday.selectors.ts │ │ │ │ ├── sync.selector.ts │ │ │ │ ├── util.selectors.ts │ │ │ │ └── view.selectors.ts │ │ │ └── slices │ │ │ │ ├── draft.slice.ts │ │ │ │ ├── draft.slice.types.ts │ │ │ │ ├── event.slice.ts │ │ │ │ ├── someday.slice.ts │ │ │ │ ├── someday.slice.types.ts │ │ │ │ ├── sync.slice.ts │ │ │ │ ├── view.slice.ts │ │ │ │ └── week.slice.ts │ │ └── settings │ │ │ ├── selectors │ │ │ └── settings.selectors.ts │ │ │ └── slices │ │ │ └── settings.slice.ts │ ├── favicon.ico │ ├── index.html │ ├── index.tsx │ ├── public │ │ └── svg │ │ │ └── circle.svg │ ├── routers │ │ └── index.tsx │ ├── socket │ │ └── SocketProvider.tsx │ ├── store │ │ ├── index.ts │ │ ├── reducers.ts │ │ ├── sagas.ts │ │ └── store.hooks.ts │ └── views │ │ ├── Calendar │ │ ├── Calendar.form.test.tsx │ │ ├── Calendar.tsx │ │ ├── calendarView.types.ts │ │ ├── components │ │ │ ├── Dedication │ │ │ │ ├── Dedication.tsx │ │ │ │ ├── index.ts │ │ │ │ └── styled.ts │ │ │ ├── Draft │ │ │ │ ├── Draft.tsx │ │ │ │ ├── context │ │ │ │ │ ├── DraftContext.tsx │ │ │ │ │ ├── DraftProvider.tsx │ │ │ │ │ └── useDraftContext.ts │ │ │ │ ├── grid │ │ │ │ │ ├── GridDraft.tsx │ │ │ │ │ └── hooks │ │ │ │ │ │ ├── useGridMouseMove.ts │ │ │ │ │ │ └── useGridMouseUp.ts │ │ │ │ ├── hooks │ │ │ │ │ ├── actions │ │ │ │ │ │ └── useDraftActions.ts │ │ │ │ │ ├── effects │ │ │ │ │ │ └── useDraftEffects.ts │ │ │ │ │ └── state │ │ │ │ │ │ ├── useDraftForm.ts │ │ │ │ │ │ └── useDraftState.ts │ │ │ │ └── sidebar │ │ │ │ │ ├── context │ │ │ │ │ ├── SidebarDraftContext.tsx │ │ │ │ │ ├── SidebarDraftProvider.tsx │ │ │ │ │ └── useSidebarContext.ts │ │ │ │ │ └── hooks │ │ │ │ │ ├── useMousePosition.ts │ │ │ │ │ ├── useSidebarActions.ts │ │ │ │ │ ├── useSidebarEffects.ts │ │ │ │ │ └── useSidebarState.ts │ │ │ ├── Event │ │ │ │ ├── Grid │ │ │ │ │ ├── GridEvent │ │ │ │ │ │ ├── GridEvent.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── GridEventPreview │ │ │ │ │ │ ├── GridEventPreview.tsx │ │ │ │ │ │ ├── snap.grid.test.ts │ │ │ │ │ │ ├── snap.grid.ts │ │ │ │ │ │ └── styled.ts │ │ │ │ │ └── index.ts │ │ │ │ └── styled.ts │ │ │ ├── Grid │ │ │ │ ├── AllDayRow │ │ │ │ │ ├── AllDayEvent.tsx │ │ │ │ │ ├── AllDayEvents.tsx │ │ │ │ │ ├── AllDayRow.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── styled.ts │ │ │ │ ├── Columns │ │ │ │ │ ├── MainGridColumns.tsx │ │ │ │ │ ├── TimesColumn │ │ │ │ │ │ ├── TimesColumn.tsx │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── styled.ts │ │ │ │ │ └── styled.ts │ │ │ │ ├── Grid.tsx │ │ │ │ ├── MainGrid │ │ │ │ │ ├── MainGrid.tsx │ │ │ │ │ ├── MainGridEvents.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── styled.ts │ │ │ │ ├── grid.types.ts │ │ │ │ └── index.ts │ │ │ ├── Header │ │ │ │ ├── DayLabels.tsx │ │ │ │ ├── Header.tsx │ │ │ │ ├── HeaderNote │ │ │ │ │ ├── HeaderNote.tsx │ │ │ │ │ ├── header-note.util.ts │ │ │ │ │ └── styled.ts │ │ │ │ └── styled.ts │ │ │ ├── NowLine │ │ │ │ ├── NowLine.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── styled.ts │ │ │ ├── Sidebar │ │ │ │ ├── MonthTab │ │ │ │ │ ├── MonthPicker │ │ │ │ │ │ └── SidebarMonthPicker.tsx │ │ │ │ │ ├── MonthTab.tsx │ │ │ │ │ ├── SubCalendarList │ │ │ │ │ │ └── SubCalendarList.tsx │ │ │ │ │ └── styled.ts │ │ │ │ ├── Sidebar.tsx │ │ │ │ ├── SidebarIconRow │ │ │ │ │ ├── SidebarIconRow.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── SomedayTab │ │ │ │ │ ├── MonthSection │ │ │ │ │ │ └── MonthSection.tsx │ │ │ │ │ ├── SomedayEvents │ │ │ │ │ │ ├── DraggableSomedayEvent │ │ │ │ │ │ │ ├── DraggableSomedayEvent.tsx │ │ │ │ │ │ │ └── DraggableSomedayEvents.tsx │ │ │ │ │ │ ├── SomedayEvent │ │ │ │ │ │ │ ├── SomedayEvent.tsx │ │ │ │ │ │ │ └── styled.ts │ │ │ │ │ │ ├── SomedayEventContainer │ │ │ │ │ │ │ ├── SomedayEventContainer.tsx │ │ │ │ │ │ │ ├── SomedayEventRectangle.tsx │ │ │ │ │ │ │ └── styled.ts │ │ │ │ │ │ ├── SomedayEvents.tsx │ │ │ │ │ │ └── SomedayEventsContainer │ │ │ │ │ │ │ ├── AddSomedayEvent.tsx │ │ │ │ │ │ │ ├── Dropzone.tsx │ │ │ │ │ │ │ └── SomedayEventsContainer.tsx │ │ │ │ │ ├── SomedayTab.tsx │ │ │ │ │ ├── WeekSection │ │ │ │ │ │ └── WeekSection.tsx │ │ │ │ │ └── styled.ts │ │ │ │ ├── sidebar.types.ts │ │ │ │ └── styled.ts │ │ │ └── TodayButton │ │ │ │ ├── TodayButton.tsx │ │ │ │ ├── index.ts │ │ │ │ └── styled.ts │ │ ├── hooks │ │ │ ├── grid │ │ │ │ ├── useDateCalcs.ts │ │ │ │ ├── useDragEventSmartScroll.ts │ │ │ │ ├── useGridEventMouseDown.test.ts │ │ │ │ ├── useGridEventMouseDown.ts │ │ │ │ ├── useGridLayout.ts │ │ │ │ └── useScroll.ts │ │ │ ├── mouse │ │ │ │ └── useEventListener.ts │ │ │ ├── shortcuts │ │ │ │ ├── useFocusHotkey.ts │ │ │ │ └── useShortcuts.ts │ │ │ ├── usePreferences.ts │ │ │ ├── useRefresh.ts │ │ │ ├── useToday.ts │ │ │ └── useWeek.ts │ │ ├── index.tsx │ │ ├── layout.constants.ts │ │ └── styled.ts │ │ ├── CmdPalette │ │ ├── CmdPalette.tsx │ │ └── index.ts │ │ ├── Forms │ │ ├── EventForm │ │ │ ├── DateControlsSection │ │ │ │ ├── DateTimeSection │ │ │ │ │ ├── DatePickers │ │ │ │ │ │ ├── DatePickers.tsx │ │ │ │ │ │ └── styled.ts │ │ │ │ │ ├── DateTimeSection.tsx │ │ │ │ │ ├── TimePicker │ │ │ │ │ │ ├── TimePicker.tsx │ │ │ │ │ │ ├── TimePickers.tsx │ │ │ │ │ │ └── styled.ts │ │ │ │ │ ├── form.datetime.util.ts │ │ │ │ │ └── styled.ts │ │ │ │ ├── RecurrenceSection │ │ │ │ │ ├── RecurrenceSection.tsx │ │ │ │ │ ├── recurrence.test.ts │ │ │ │ │ ├── styled.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── index.tsx │ │ │ │ └── styled.ts │ │ │ ├── DeleteButton.tsx │ │ │ ├── DuplicateButton.tsx │ │ │ ├── EventForm.test.tsx │ │ │ ├── EventForm.tsx │ │ │ ├── MigrateBackwardButton.tsx │ │ │ ├── MigrateForwardButton.tsx │ │ │ ├── MoveToSidebarButton.tsx │ │ │ ├── PrioritySection │ │ │ │ ├── PrioritySection.tsx │ │ │ │ ├── index.ts │ │ │ │ └── styled.ts │ │ │ ├── RepeatSection │ │ │ │ ├── RepeatDialog │ │ │ │ │ ├── RepeatDialog.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── RepeatSection.tsx │ │ │ │ ├── index.ts │ │ │ │ └── styled.ts │ │ │ ├── SaveSection │ │ │ │ ├── SaveSection.tsx │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── styled.ts │ │ │ └── types.ts │ │ ├── SomedayEventForm │ │ │ ├── SomedayEventForm.test.tsx │ │ │ ├── SomedayEventForm.tsx │ │ │ └── styled.ts │ │ └── hooks │ │ │ └── useEventForm.ts │ │ ├── Login │ │ ├── Login.tsx │ │ ├── index.ts │ │ └── styled.ts │ │ ├── Logout │ │ ├── Logout.tsx │ │ ├── index.ts │ │ └── styled.ts │ │ └── NotFound │ │ ├── NotFound.tsx │ │ ├── index.ts │ │ └── styled.ts │ ├── tsconfig.json │ └── webpack.config.js ├── tsconfig.build.json ├── tsconfig.json └── yarn.lock /.gitattributes: -------------------------------------------------------------------------------- 1 | * text eol=lf 2 | *.png binary 3 | *.jpg binary 4 | *.jpeg binary 5 | *.ico binary 6 | *.icns binary 7 | *.eot binary 8 | *.otf binary 9 | *.ttf binary 10 | *.woff binary 11 | *.woff2 binary 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1-feature-request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request ✨ 2 | description: You want something added to the app 3 | labels: [enhancement] 4 | projects: [SwitchbackTech/4] 5 | 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | Thanks for taking the time to fill out this feature request! 11 | 12 | - type: textarea 13 | attributes: 14 | label: Feature Description 15 | description: Describe the feature you would like to see added. 16 | placeholder: New Thing should work like this ... 17 | 18 | - type: textarea 19 | attributes: 20 | label: Use Case 21 | description: Explain the use case for this feature. How will it benefit users? 22 | placeholder: This'll save users time by ... 23 | 24 | - type: textarea 25 | attributes: 26 | label: Additional Context 27 | description: Add any other context or screenshots about the feature request here. 28 | placeholder: Here's where I got this idea ... 29 | -------------------------------------------------------------------------------- /.github/config.yml: -------------------------------------------------------------------------------- 1 | requiredHeaders: 2 | - Prerequisites 3 | - Expected Behavior 4 | - Current Behavior 5 | - Possible Solution 6 | - Your Environment 7 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | target-branch: "dependencies" 11 | commit-message: 12 | prefix: "⬆️" 13 | schedule: 14 | interval: "daily" 15 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pr 8 | - discussion 9 | - e2e 10 | - enhancement 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: false 18 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | install-test-compile: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Check out Git repository 11 | uses: actions/checkout@v4 12 | 13 | - name: Install Node.js and Yarn 14 | uses: actions/setup-node@v4 15 | with: 16 | node-version: 20 17 | cache: "yarn" 18 | 19 | - name: Install Dependencies 20 | run: | 21 | yarn install --frozen-lockfile --network-timeout 300000 22 | 23 | - name: Run tests 24 | run: | 25 | yarn test 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ######### 2 | # FILES # 3 | ######### 4 | build.zip 5 | .DS_Store 6 | .eslintcache 7 | .idea/ 8 | .vscode/ 9 | globalConfig.json 10 | sendgrid.env 11 | credentials.txt 12 | 13 | # wildcards 14 | *.env* 15 | *.log 16 | *.mongodb 17 | *.tsbuildinfo 18 | packages/**/yarn.lock 19 | 20 | ######## 21 | # DIRS # 22 | ######## 23 | # root 24 | build/ 25 | buildcache/ 26 | logs/ 27 | node_modules/ 28 | 29 | #core 30 | packages/core/build/ 31 | 32 | # web 33 | packages/web/node_modules/ 34 | packages/web/build/ 35 | 36 | # backend 37 | packages/backend/logs 38 | packages/backend/build 39 | packages/backend/node_modules 40 | packages/backend/playgroud 41 | packages/backend/.env 42 | packages/backend/.prod.env 43 | 44 | 45 | !.env.example -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn lint-staged 5 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn prettier . --write -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts: 2 | build 3 | coverage 4 | logs 5 | .eslintrc.json -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["@trivago/prettier-plugin-sort-imports"], 3 | "importOrder": [ 4 | "", 5 | "^@(?!core|web|backend).*$", 6 | "^@core/(.*)$", 7 | "^@web/(.*)$", 8 | "^@backend/(.*)$", 9 | "^[./]" 10 | ], 11 | "importOrderSeparation": false, 12 | "importOrderSortSpecifiers": true 13 | } 14 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # AGENTS 2 | 3 | ## Project Overview 4 | 5 | ### Project Structure 6 | 7 | Repo type: monorepo with Yarn workspaces, where each `packages/*` directory is an independent package. 8 | 9 | ## Conventions 10 | 11 | Follow the conventions explained on this page: 12 | 13 | ## Setup 14 | 15 | - Use Node LTS 16 | - Use Yarn for package management 17 | - Install all dependencies: `yarn` 18 | 19 | ## Testing 20 | 21 | - Run the full test suite before submitting a PR. The command is `yarn test`. 22 | - New tests should be co-located next to the code they are testing (not in a separate `tests` directory) 23 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 🤝 2 | 3 | The Compass Code of Conduct is available here: 4 | [https://docs.compasscalendar.com/docs/how-to-contribute/code-of-conduct](https://docs.compasscalendar.com/docs/how-to-contribute/code-of-conduct) 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution Guide️ 👷‍♀️👷‍♂️ 2 | 3 | The Compass Contribution Guide is available on the official doc site: 4 | [https://docs.compasscalendar.com/docs/how-to-contribute/contribute](https://docs.compasscalendar.com/docs/how-to-contribute/contribute) 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2023 Tyler Dane Hitzeman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | If you discover a security vulnerability in this project, please send an email to **tyler@switchback.tech** with the following details: 6 | 7 | - A description of the vulnerability 8 | - Steps to reproduce (if applicable) 9 | - Any relevant logs, screenshots, or details that can help in identifying the issue 10 | 11 | **Please do not disclose the vulnerability publicly until it has been addressed.** 12 | 13 | I will make every effort to respond within 48 hours and provide a timeline for a fix. Thank you for helping to keep this project secure! 14 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | "@babel/preset-react", 4 | ["@babel/preset-env", { targets: { node: "current" } }], 5 | "@babel/preset-typescript", 6 | ], 7 | plugins: [ 8 | [ 9 | "babel-plugin-styled-components", 10 | { 11 | meaninglessFileNames: ["index", "styled"], 12 | pure: true, 13 | }, 14 | ], 15 | ], 16 | }; 17 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": ["packages/*"], 3 | "npmClient": "yarn", 4 | "useWorkspaces": true, 5 | "version": "0.0.2" 6 | } 7 | -------------------------------------------------------------------------------- /packages/backend/globalConfig.json: -------------------------------------------------------------------------------- 1 | { "mongoUri": "mongodb://127.0.0.1:60338/" } 2 | -------------------------------------------------------------------------------- /packages/backend/jest-mongodb-config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | mongodbMemoryServerOptions: { 3 | binary: { 4 | version: "4.0.3", 5 | skipMD5: true, 6 | }, 7 | instance: {}, 8 | autoStart: false, 9 | }, 10 | useSharedDBForAllJestWorkers: false, 11 | }; 12 | -------------------------------------------------------------------------------- /packages/backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@compass/backend", 3 | "description": "REST API backend", 4 | "license": "MIT", 5 | "version": "1.0.0", 6 | "main": "build/src/app.js", 7 | "dependencies": { 8 | "@compass/core": "1.0.0", 9 | "chalk": "4.1.2", 10 | "cors": "^2.8.5", 11 | "dotenv": "^16.0.0", 12 | "express": "^4.17.1", 13 | "express-validator": "^6.12.2", 14 | "express-winston": "^4.2.0", 15 | "googleapis": "^112.0.0", 16 | "helmet": "^7.0.0", 17 | "jsonwebtoken": "^9.0.0", 18 | "mongodb": "6.3", 19 | "morgan": "^1.10.0", 20 | "rrule": "^2.7.2", 21 | "saslprep": "^1.0.3", 22 | "socket.io": "^4.7.5", 23 | "supertokens-node": "^20.0.5", 24 | "tslib": "^2.4.0", 25 | "zod": "^3.24.1" 26 | }, 27 | "devDependencies": { 28 | "@faker-js/faker": "^9.6.0", 29 | "@shelf/jest-mongodb": "^4.1.4", 30 | "@types/cors": "^2.8.12", 31 | "@types/debug": "^4.1.7", 32 | "@types/express": "^4.17.13", 33 | "@types/gapi": "0.0.41", 34 | "@types/jest": "^29.0.0", 35 | "@types/jsonwebtoken": "^9.0.1", 36 | "@types/module-alias": "^2.0.1", 37 | "@types/morgan": "^1.9.4", 38 | "@types/node": "^22.13.10", 39 | "@types/supertest": "^6.0.3", 40 | "alias-hq": "^6.1.0", 41 | "jest-environment-node": "^29.7.0", 42 | "socket.io-client": "^4.7.5", 43 | "supertest": "^7.1.0", 44 | "tsconfig-paths": "^4.1.2" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /packages/backend/src/__tests__/backend.test.init.js: -------------------------------------------------------------------------------- 1 | process.env.BASEURL = "https://foo.yourdomain.app"; 2 | process.env.CORS = 3 | "https://foo.yourdomain.app,http://localhost:3000,http://localhost:9080"; 4 | process.env.LOG_LEVEL = "debug"; 5 | process.env.NODE_ENV = "test"; 6 | process.env.PORT = 3000; 7 | 8 | process.env.MONGO_URI = "foo"; 9 | process.env.CLIENT_ID = "googleClientId"; 10 | process.env.CLIENT_SECRET = "googleSecret"; 11 | process.env.CHANNEL_EXPIRATION_MIN = 5; 12 | process.env.SUPERTOKENS_URI = "sTUri"; 13 | process.env.SUPERTOKENS_KEY = "sTKey"; 14 | process.env.EMAILER_API_SECRET = "emailerApiSecret"; 15 | process.env.EMAILER_WAITLIST_TAG_ID = 1234567; 16 | process.env.EMAILER_WAITLIST_INVITE_TAG_ID = 7654321; 17 | process.env.EMAILER_USER_TAG_ID = 910111213; 18 | process.env.TOKEN_GCAL_NOTIFICATION = "secretToken1"; 19 | process.env.TOKEN_COMPASS_SYNC = "secretToken2"; 20 | -------------------------------------------------------------------------------- /packages/backend/src/__tests__/backend.test.start.js: -------------------------------------------------------------------------------- 1 | jest.mock("@core/logger/winston.logger", () => { 2 | const mockLogger = { 3 | debug: jest.fn(), 4 | info: jest.fn(), 5 | warn: jest.fn(), 6 | error: jest.fn(), 7 | verbose: jest.fn(), 8 | }; 9 | 10 | return { 11 | Logger: jest.fn().mockImplementation(() => mockLogger), 12 | }; 13 | }); 14 | 15 | afterAll(() => { 16 | jest.clearAllMocks(); 17 | }); 18 | -------------------------------------------------------------------------------- /packages/backend/src/__tests__/mocks.gcal/errors/error.google.invalidGrant.ts: -------------------------------------------------------------------------------- 1 | import { GaxiosError } from "googleapis-common"; 2 | 3 | export const invalidGrant400Error = new GaxiosError( 4 | "invalid_grant", 5 | {}, 6 | { 7 | config: { 8 | method: "POST", 9 | body: "'refresh_token=1%2F%2F01F_IBIEZx2TUCgYIARAAGAESNgF-L9IrE_2hx29jFovRvv0eIB_pNYnQsFy8QqSlmsq6nzCENaKrnItvDp72Qg9tF3OtDZrdwAZ&client_id=111711111111-mqq17c111hgpgn907j79kgnse1o0lchk.apps.googleusercontent.com&client_secret=Z8o&grant_type=refresh_token'", 10 | data: "refresh_token=1%2F%2F01F_IBIEZx2TUCgYIARAAGAESNgF-L9IrE_2hx29jFovRvv0eIB_pNYnQsFy8QqSlmsq6nzCENaKrnItvDp72Qg9tF3OtDZrdwAZ&client_id=111711111111-mqq17c111hgpgn907j79kgnse1o0lchk.apps.googleusercontent.com&client_secret=Z8o&grant_type=refresh_token", 11 | url: "https://oauth2.googleapis.com/token", 12 | responseType: "json", 13 | }, 14 | data: { 15 | error: "invalid_grant", 16 | error_description: "Bad Request", 17 | }, 18 | status: 400, 19 | statusText: "Bad Request", 20 | headers: {}, 21 | request: { 22 | responseURL: "", 23 | }, 24 | }, 25 | ); 26 | -------------------------------------------------------------------------------- /packages/backend/src/__tests__/mocks.gcal/fixtures/recurring/delete/all/all-1.ts: -------------------------------------------------------------------------------- 1 | import { gSchema$Events } from "@core/types/gcal"; 2 | 3 | export const deleteAllPayload1: gSchema$Events = { 4 | kind: "calendar#events", 5 | etag: '"p32nsfe61kqioo0o"', 6 | summary: "test.user@gmail.com", 7 | description: "", 8 | updated: "2025-03-25T13:07:46.505Z", 9 | timeZone: "America/Chicago", 10 | accessRole: "owner", 11 | defaultReminders: [ 12 | { 13 | method: "popup", 14 | minutes: 30, 15 | }, 16 | ], 17 | nextSyncToken: "CK_HuMGmpYwDEK_HuMGmpYwDGAUg24mI4AIo24mI4AI=", 18 | items: [ 19 | { 20 | kind: "calendar#event", 21 | etag: '"3485816133011294"', 22 | id: "6pbua6vjeic3e73gvponeo1qp6", 23 | status: "cancelled", 24 | }, 25 | ], 26 | }; 27 | -------------------------------------------------------------------------------- /packages/backend/src/__tests__/mocks.gcal/fixtures/recurring/delete/all/all-2.ts: -------------------------------------------------------------------------------- 1 | import { gSchema$Events } from "@core/types/gcal"; 2 | 3 | export const deleteAllPayload2: gSchema$Events = { 4 | kind: "calendar#events", 5 | etag: '"p33nv5euikqioo0o"', 6 | summary: "test.user@gmail.com", 7 | description: "", 8 | updated: "2025-03-25T13:08:22.211Z", 9 | timeZone: "America/Chicago", 10 | accessRole: "owner", 11 | defaultReminders: [ 12 | { 13 | method: "popup", 14 | minutes: 30, 15 | }, 16 | ], 17 | nextSyncToken: "CO_yu9KmpYwDEO_yu9KmpYwDGAUg24mI4AIo24mI4AI=", 18 | items: [ 19 | { 20 | kind: "calendar#event", 21 | etag: '"3485816204423902"', 22 | id: "0oije1knkkthb56vr149dub9qa", 23 | status: "cancelled", 24 | }, 25 | ], 26 | }; 27 | -------------------------------------------------------------------------------- /packages/backend/src/__tests__/mocks.gcal/fixtures/recurring/delete/all/index.ts: -------------------------------------------------------------------------------- 1 | import { deleteAllPayload1 } from "./all-1"; 2 | import { deleteAllPayload2 } from "./all-2"; 3 | 4 | export const deleteAllPayloads = [deleteAllPayload1, deleteAllPayload2]; 5 | -------------------------------------------------------------------------------- /packages/backend/src/__tests__/mocks.gcal/fixtures/recurring/delete/single/index.ts: -------------------------------------------------------------------------------- 1 | import { deleteSinglePayload } from "./delete.single"; 2 | import { deleteSinglePayload2 } from "./delete.single-2"; 3 | 4 | export const deleteSingleEventPayloads = [ 5 | deleteSinglePayload, 6 | deleteSinglePayload2, 7 | ]; 8 | -------------------------------------------------------------------------------- /packages/backend/src/__tests__/mocks.gcal/fixtures/recurring/delete/this-and-following/index.ts: -------------------------------------------------------------------------------- 1 | import { deleteThisAndFollowingPayload } from "./delete.this-and-following"; 2 | import { deleteThisAndFollowingPayload2 } from "./delete.this-and-following-2"; 3 | 4 | export const deleteThisAndFollowingPayloads = [ 5 | deleteThisAndFollowingPayload, 6 | deleteThisAndFollowingPayload2, 7 | ]; 8 | -------------------------------------------------------------------------------- /packages/backend/src/__tests__/mocks.gcal/fixtures/recurring/edit/all/index.ts: -------------------------------------------------------------------------------- 1 | import { allPayload1 } from "./all-1"; 2 | import { allPayload2 } from "./all-2"; 3 | 4 | export const editAllPayloads = [allPayload1, allPayload2]; 5 | -------------------------------------------------------------------------------- /packages/backend/src/__tests__/mocks.gcal/fixtures/recurring/edit/single/index.ts: -------------------------------------------------------------------------------- 1 | import { singleInstance1Payload } from "./single.instance-1"; 2 | import { singleInstance2Payload } from "./single.instance-2"; 3 | import { singleInstance3Payload } from "./single.instance-3"; 4 | 5 | export const editSingleEventPayloads = [ 6 | singleInstance1Payload, 7 | singleInstance2Payload, 8 | singleInstance3Payload, 9 | ]; 10 | -------------------------------------------------------------------------------- /packages/backend/src/__tests__/mocks.gcal/fixtures/recurring/edit/this-and-following-v2/index.ts: -------------------------------------------------------------------------------- 1 | import { thisAndFollowing0Payload } from "./this-and-following-0"; 2 | import { thisAndFollowing1Payload } from "./this-and-following-1"; 3 | 4 | export const thisAndFollowingV2Payloads = [ 5 | thisAndFollowing0Payload, 6 | thisAndFollowing1Payload, 7 | ]; 8 | -------------------------------------------------------------------------------- /packages/backend/src/__tests__/mocks.gcal/fixtures/recurring/edit/this-and-following/index.ts: -------------------------------------------------------------------------------- 1 | import { thisAndFollowing0Payload } from "../this-and-following-v2/this-and-following-0"; 2 | import { thisAndFollowing1Payload } from "./this-and-following-1"; 3 | import { thisAndFollowing2Payload } from "./this-and-following-2"; 4 | import { thisAndFollowing3Payload } from "./this-and-following-3"; 5 | import { thisAndFollowing4Payload } from "./this-and-following-4"; 6 | 7 | export const editThisAndFollowingPayloads = [ 8 | thisAndFollowing0Payload, 9 | thisAndFollowing1Payload, 10 | thisAndFollowing2Payload, 11 | thisAndFollowing3Payload, 12 | thisAndFollowing4Payload, 13 | ]; 14 | -------------------------------------------------------------------------------- /packages/backend/src/__tests__/mocks.gcal/mocks.gcal/errors/error.google.invalidGrant.ts: -------------------------------------------------------------------------------- 1 | import { GaxiosError } from "googleapis-common"; 2 | 3 | export const invalidGrant400Error = new GaxiosError( 4 | "invalid_grant", 5 | {}, 6 | { 7 | config: { 8 | method: "POST", 9 | body: "'refresh_token=1%2F%2F01F_IBIEZx2TUCgYIARAAGAESNgF-L9IrE_2hx29jFovRvv0eIB_pNYnQsFy8QqSlmsq6nzCENaKrnItvDp72Qg9tF3OtDZrdwAZ&client_id=111711111111-mqq17c111hgpgn907j79kgnse1o0lchk.apps.googleusercontent.com&client_secret=Z8o&grant_type=refresh_token'", 10 | data: "refresh_token=1%2F%2F01F_IBIEZx2TUCgYIARAAGAESNgF-L9IrE_2hx29jFovRvv0eIB_pNYnQsFy8QqSlmsq6nzCENaKrnItvDp72Qg9tF3OtDZrdwAZ&client_id=111711111111-mqq17c111hgpgn907j79kgnse1o0lchk.apps.googleusercontent.com&client_secret=Z8o&grant_type=refresh_token", 11 | url: "https://oauth2.googleapis.com/token", 12 | responseType: "json", 13 | }, 14 | data: { 15 | error: "invalid_grant", 16 | error_description: "Bad Request", 17 | }, 18 | status: 400, 19 | statusText: "Bad Request", 20 | headers: {}, 21 | request: { 22 | responseURL: "", 23 | }, 24 | }, 25 | ); 26 | -------------------------------------------------------------------------------- /packages/backend/src/__tests__/mocks.gcal/mocks.gcal/factories/gcal.event.factory.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | mockRecurringInstances, 3 | mockTimedRecurrence, 4 | } from "./gcal.event.factory"; 5 | 6 | describe("mockRecurringInstances", () => { 7 | it("should create recurring instances", () => { 8 | const event = mockTimedRecurrence(); 9 | const instances = mockRecurringInstances(event, 3, 7); 10 | expect(instances).toHaveLength(3); 11 | }); 12 | it("should use RFC3339_OFFSET for start and end times", () => { 13 | const event = mockTimedRecurrence(); 14 | const instances = mockRecurringInstances(event, 3, 7); 15 | const hasTZOffset = (ts: string) => { 16 | return ( 17 | // @ts-expect-error assuming string has enough length 18 | ts[ts.length - 3] === ":" && ["+", "-"].includes(ts[ts.length - 6]) 19 | ); 20 | }; 21 | instances.forEach((instance) => { 22 | expect(hasTZOffset(instance.start?.dateTime as string)).toBe(true); 23 | expect(hasTZOffset(instance.end?.dateTime as string)).toBe(true); 24 | }); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /packages/backend/src/__tests__/mocks.gcal/mocks.gcal/fixtures/recurring/delete/all/all-1.ts: -------------------------------------------------------------------------------- 1 | import { gSchema$Events } from "@core/types/gcal"; 2 | 3 | export const deleteAllPayload1: gSchema$Events = { 4 | kind: "calendar#events", 5 | etag: '"p32nsfe61kqioo0o"', 6 | summary: "test.user@gmail.com", 7 | description: "", 8 | updated: "2025-03-25T13:07:46.505Z", 9 | timeZone: "America/Chicago", 10 | accessRole: "owner", 11 | defaultReminders: [ 12 | { 13 | method: "popup", 14 | minutes: 30, 15 | }, 16 | ], 17 | nextSyncToken: "CK_HuMGmpYwDEK_HuMGmpYwDGAUg24mI4AIo24mI4AI=", 18 | items: [ 19 | { 20 | kind: "calendar#event", 21 | etag: '"3485816133011294"', 22 | id: "6pbua6vjeic3e73gvponeo1qp6", 23 | status: "cancelled", 24 | }, 25 | ], 26 | }; 27 | -------------------------------------------------------------------------------- /packages/backend/src/__tests__/mocks.gcal/mocks.gcal/fixtures/recurring/delete/all/all-2.ts: -------------------------------------------------------------------------------- 1 | import { gSchema$Events } from "@core/types/gcal"; 2 | 3 | export const deleteAllPayload2: gSchema$Events = { 4 | kind: "calendar#events", 5 | etag: '"p33nv5euikqioo0o"', 6 | summary: "test.user@gmail.com", 7 | description: "", 8 | updated: "2025-03-25T13:08:22.211Z", 9 | timeZone: "America/Chicago", 10 | accessRole: "owner", 11 | defaultReminders: [ 12 | { 13 | method: "popup", 14 | minutes: 30, 15 | }, 16 | ], 17 | nextSyncToken: "CO_yu9KmpYwDEO_yu9KmpYwDGAUg24mI4AIo24mI4AI=", 18 | items: [ 19 | { 20 | kind: "calendar#event", 21 | etag: '"3485816204423902"', 22 | id: "0oije1knkkthb56vr149dub9qa", 23 | status: "cancelled", 24 | }, 25 | ], 26 | }; 27 | -------------------------------------------------------------------------------- /packages/backend/src/__tests__/mocks.gcal/mocks.gcal/fixtures/recurring/delete/all/index.ts: -------------------------------------------------------------------------------- 1 | import { deleteAllPayload1 } from "./all-1"; 2 | import { deleteAllPayload2 } from "./all-2"; 3 | 4 | export const deleteAllPayloads = [deleteAllPayload1, deleteAllPayload2]; 5 | -------------------------------------------------------------------------------- /packages/backend/src/__tests__/mocks.gcal/mocks.gcal/fixtures/recurring/delete/single/index.ts: -------------------------------------------------------------------------------- 1 | import { deleteSinglePayload } from "./delete.single"; 2 | import { deleteSinglePayload2 } from "./delete.single-2"; 3 | 4 | export const deleteSingleEventPayloads = [ 5 | deleteSinglePayload, 6 | deleteSinglePayload2, 7 | ]; 8 | -------------------------------------------------------------------------------- /packages/backend/src/__tests__/mocks.gcal/mocks.gcal/fixtures/recurring/delete/this-and-following/index.ts: -------------------------------------------------------------------------------- 1 | import { deleteThisAndFollowingPayload } from "./delete.this-and-following"; 2 | import { deleteThisAndFollowingPayload2 } from "./delete.this-and-following-2"; 3 | 4 | export const deleteThisAndFollowingPayloads = [ 5 | deleteThisAndFollowingPayload, 6 | deleteThisAndFollowingPayload2, 7 | ]; 8 | -------------------------------------------------------------------------------- /packages/backend/src/__tests__/mocks.gcal/mocks.gcal/fixtures/recurring/edit/all/index.ts: -------------------------------------------------------------------------------- 1 | import { allPayload1 } from "./all-1"; 2 | import { allPayload2 } from "./all-2"; 3 | 4 | export const editAllPayloads = [allPayload1, allPayload2]; 5 | -------------------------------------------------------------------------------- /packages/backend/src/__tests__/mocks.gcal/mocks.gcal/fixtures/recurring/edit/single/index.ts: -------------------------------------------------------------------------------- 1 | import { singleInstance1Payload } from "./single.instance-1"; 2 | import { singleInstance2Payload } from "./single.instance-2"; 3 | import { singleInstance3Payload } from "./single.instance-3"; 4 | import { singleInstance4Payload } from "./single.instance-4"; 5 | 6 | export const editSingleEventPayloads = [ 7 | singleInstance1Payload, 8 | singleInstance2Payload, 9 | singleInstance3Payload, 10 | singleInstance4Payload, 11 | ]; 12 | -------------------------------------------------------------------------------- /packages/backend/src/__tests__/mocks.gcal/mocks.gcal/fixtures/recurring/edit/this-and-following/batch1/index.ts: -------------------------------------------------------------------------------- 1 | import { thisAndFollowing0Payload } from "./this-and-following-v2-0"; 2 | import { thisAndFollowing1Payload } from "./this-and-following-v2-1"; 3 | import { thisAndFollowing2Payload } from "./this-and-following-v2-2"; 4 | 5 | export const thisAndFollowingBatch1Payloads = [ 6 | thisAndFollowing0Payload, 7 | thisAndFollowing1Payload, 8 | thisAndFollowing2Payload, 9 | ]; 10 | -------------------------------------------------------------------------------- /packages/backend/src/__tests__/mocks.gcal/mocks.gcal/fixtures/recurring/edit/this-and-following/batch2/index.ts: -------------------------------------------------------------------------------- 1 | import { thisAndFollowing1Payload } from "./this-and-following-1"; 2 | import { thisAndFollowing2Payload } from "./this-and-following-2"; 3 | import { thisAndFollowing3Payload } from "./this-and-following-3"; 4 | import { thisAndFollowing4Payload } from "./this-and-following-4"; 5 | 6 | export const thisAndFollowingBatch2Payloads = [ 7 | thisAndFollowing1Payload, 8 | thisAndFollowing2Payload, 9 | thisAndFollowing3Payload, 10 | thisAndFollowing4Payload, 11 | ]; 12 | -------------------------------------------------------------------------------- /packages/backend/src/__tests__/mocks.gcal/mocks.gcal/fixtures/recurring/edit/this-and-following/index.ts: -------------------------------------------------------------------------------- 1 | import * as batch1 from "./batch1"; 2 | import * as batch2 from "./batch2"; 3 | 4 | export const thisAndFollowingPayloads = [ 5 | ...batch1.thisAndFollowingBatch1Payloads, 6 | ...batch2.thisAndFollowingBatch2Payloads, 7 | ]; 8 | -------------------------------------------------------------------------------- /packages/backend/src/app.ts: -------------------------------------------------------------------------------- 1 | // sort-imports-ignore 2 | import * as http from "http"; 3 | import { logger } from "./init"; 4 | // eslint-disable-next-line prettier/prettier 5 | import { ENV } from "./common/constants/env.constants"; 6 | import mongoService from "./common/services/mongo.service"; 7 | import { initExpressServer } from "./servers/express/express.server"; 8 | import { webSocketServer } from "./servers/websocket/websocket.server"; 9 | 10 | mongoService; 11 | 12 | const app = initExpressServer(); 13 | const httpServer: http.Server = http.createServer(app); 14 | webSocketServer.init(httpServer); 15 | 16 | httpServer.listen(ENV.PORT, () => { 17 | logger.info(`Server running on port: ${ENV.PORT}`); 18 | }); 19 | -------------------------------------------------------------------------------- /packages/backend/src/auth/services/auth.utils.ts: -------------------------------------------------------------------------------- 1 | import { Credentials } from "google-auth-library"; 2 | import GoogleAuthService from "@backend/auth/services/google.auth.service"; 3 | import { AuthError } from "@backend/common/errors/auth/auth.errors"; 4 | import { error } from "@backend/common/errors/handlers/error.handler"; 5 | 6 | export const initGoogleClient = async ( 7 | gAuthClient: GoogleAuthService, 8 | tokens: Credentials, 9 | ) => { 10 | const gRefreshToken = tokens.refresh_token; 11 | if (!gRefreshToken) { 12 | throw error(AuthError.MissingRefreshToken, "Failed to auth to user's gCal"); 13 | } 14 | 15 | gAuthClient.oauthClient.setCredentials(tokens); 16 | 17 | const { gUser } = await gAuthClient.getGoogleUserInfo(); 18 | 19 | const gcalClient = gAuthClient.getGcalClient(); 20 | 21 | return { gUser, gcalClient, gRefreshToken }; 22 | }; 23 | -------------------------------------------------------------------------------- /packages/backend/src/calendar/calendar.routes.config.ts: -------------------------------------------------------------------------------- 1 | import { Application } from "express"; 2 | import { verifySession } from "supertokens-node/recipe/session/framework/express"; 3 | import { CommonRoutesConfig } from "@backend/common/common.routes.config"; 4 | import calendarController from "./controllers/calendar.controller"; 5 | 6 | export class CalendarRoutes extends CommonRoutesConfig { 7 | constructor(app: Application) { 8 | super(app, "CalendarRoutes"); 9 | } 10 | 11 | configureRoutes() { 12 | this.app 13 | .route(`/api/calendarlist`) 14 | .all(verifySession()) 15 | //@ts-ignore 16 | .get(calendarController.list) 17 | //@ts-ignore 18 | .post(calendarController.create); 19 | return this.app; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/backend/src/common/common.routes.config.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | 3 | export abstract class CommonRoutesConfig { 4 | app: express.Application; 5 | name: string; 6 | 7 | constructor(app: express.Application, name: string) { 8 | this.app = app; 9 | this.name = name; 10 | this.configureRoutes(); 11 | } 12 | getName() { 13 | return this.name; 14 | } 15 | abstract configureRoutes(): express.Application; 16 | } 17 | -------------------------------------------------------------------------------- /packages/backend/src/common/constants/backend.constants.ts: -------------------------------------------------------------------------------- 1 | export const GCAL_PRIMARY = "primary"; 2 | 3 | export const SYNC_BUFFER_DAYS = 3; 4 | -------------------------------------------------------------------------------- /packages/backend/src/common/constants/collections.ts: -------------------------------------------------------------------------------- 1 | import { IS_DEV } from "./env.constants"; 2 | 3 | export const Collections = { 4 | CALENDARLIST: IS_DEV ? "_dev.calendarlist" : "calendarlist", 5 | EVENT: IS_DEV ? "_dev.event" : "event", 6 | OAUTH: IS_DEV ? "_dev.oauth" : "oauth", 7 | PRIORITY: IS_DEV ? "_dev.priority" : "priority", 8 | SYNC: IS_DEV ? "_dev.sync" : "sync", 9 | USER: IS_DEV ? "_dev.user" : "user", 10 | WAITLIST: IS_DEV ? "_dev.waitlist" : "waitlist", 11 | }; 12 | -------------------------------------------------------------------------------- /packages/backend/src/common/constants/env.util.ts: -------------------------------------------------------------------------------- 1 | import { ENV } from "./env.constants"; 2 | 3 | export const isMissingUserTagId = () => { 4 | return !ENV.EMAILER_SECRET || !ENV.EMAILER_USER_TAG_ID; 5 | }; 6 | 7 | export const isMissingWaitlistTagId = () => { 8 | return !ENV.EMAILER_SECRET || !ENV.EMAILER_WAITLIST_TAG_ID; 9 | }; 10 | 11 | export const isMissingWaitlistInviteTagId = () => { 12 | return !ENV.EMAILER_SECRET || !ENV.EMAILER_WAITLIST_INVITE_TAG_ID; 13 | }; 14 | -------------------------------------------------------------------------------- /packages/backend/src/common/errors/auth/auth.errors.ts: -------------------------------------------------------------------------------- 1 | import { Status } from "@core/errors/status.codes"; 2 | import { ErrorMetadata } from "@backend/common/types/error.types"; 3 | 4 | interface AuthErrors { 5 | DevOnly: ErrorMetadata; 6 | InadequatePermissions: ErrorMetadata; 7 | MissingRefreshToken: ErrorMetadata; 8 | NoUserId: ErrorMetadata; 9 | NoGAuthAccessToken: ErrorMetadata; 10 | } 11 | 12 | export const AuthError: AuthErrors = { 13 | DevOnly: { 14 | description: "Only available during development", 15 | status: Status.FORBIDDEN, 16 | isOperational: true, 17 | }, 18 | InadequatePermissions: { 19 | description: "You don't have permission to do that", 20 | status: Status.FORBIDDEN, 21 | isOperational: true, 22 | }, 23 | MissingRefreshToken: { 24 | description: "No refresh token", 25 | status: Status.BAD_REQUEST, 26 | isOperational: true, 27 | }, 28 | NoUserId: { 29 | description: "Compass user was not created", 30 | status: Status.INTERNAL_SERVER, 31 | isOperational: false, 32 | }, 33 | NoGAuthAccessToken: { 34 | description: "No gauth access token", 35 | status: Status.BAD_REQUEST, 36 | isOperational: true, 37 | }, 38 | }; 39 | -------------------------------------------------------------------------------- /packages/backend/src/common/errors/db/db.errors.ts: -------------------------------------------------------------------------------- 1 | import { Status } from "@core/errors/status.codes"; 2 | import { ErrorMetadata } from "@backend/common/types/error.types"; 3 | 4 | interface DbErrors { 5 | InvalidId: ErrorMetadata; 6 | } 7 | export const DbError: DbErrors = { 8 | InvalidId: { 9 | description: "id is invalid (according to Mongo)", 10 | status: Status.BAD_REQUEST, 11 | isOperational: true, 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /packages/backend/src/common/errors/emailer/emailer.errors.ts: -------------------------------------------------------------------------------- 1 | import { Status } from "@core/errors/status.codes"; 2 | import { ErrorMetadata } from "@backend/common/types/error.types"; 3 | 4 | interface EmailerErrors { 5 | InvalidTagId: ErrorMetadata; 6 | InvalidSecret: ErrorMetadata; 7 | InvalidSubscriberData: ErrorMetadata; 8 | } 9 | 10 | export const EmailerError: EmailerErrors = { 11 | InvalidSubscriberData: { 12 | description: "Subscriber data is missing or incorrect", 13 | status: Status.BAD_REQUEST, 14 | isOperational: true, 15 | }, 16 | InvalidSecret: { 17 | description: 18 | "Invalid emailer API secret. Please make sure environment variables beginning with EMAILER_ are correct", 19 | status: Status.INTERNAL_SERVER, 20 | isOperational: true, 21 | }, 22 | InvalidTagId: { 23 | description: 24 | "Invalid emailer tag id. Please make sure environment variables beginning with EMAILER_ are correct", 25 | status: Status.INTERNAL_SERVER, 26 | isOperational: true, 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /packages/backend/src/common/errors/generic/generic.errors.ts: -------------------------------------------------------------------------------- 1 | import { Status } from "@core/errors/status.codes"; 2 | import { ErrorMetadata } from "@backend/common/types/error.types"; 3 | 4 | interface GenericErrors { 5 | BadRequest: ErrorMetadata; 6 | DeveloperError: ErrorMetadata; 7 | NotImplemented: ErrorMetadata; 8 | NotSure: ErrorMetadata; 9 | } 10 | 11 | export const GenericError: GenericErrors = { 12 | BadRequest: { 13 | description: "Request is malformed", 14 | status: Status.BAD_REQUEST, 15 | isOperational: true, 16 | }, 17 | DeveloperError: { 18 | description: "Developer made a logic error", 19 | status: Status.INTERNAL_SERVER, 20 | isOperational: true, 21 | }, 22 | NotImplemented: { 23 | description: "Not implemented yet", 24 | status: Status.UNSURE, 25 | isOperational: true, 26 | }, 27 | NotSure: { 28 | description: "Not sure why error occurred. See logs", 29 | status: Status.UNSURE, 30 | isOperational: true, 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /packages/backend/src/common/errors/handlers/error.unexpected.handler.ts: -------------------------------------------------------------------------------- 1 | import { errorHandler } from "./error.handler"; 2 | 3 | process.on("uncaughtException", (error: Error) => { 4 | errorHandler.log(error); 5 | if (!errorHandler.isOperational(error)) { 6 | errorHandler.exitAfterProgrammerError(); 7 | } 8 | }); 9 | 10 | // get the unhandled promise rejections/exceptions and throw it to the 11 | // `uncaughtException` fallback handler 12 | //@ts-ignore 13 | process.on("unhandledRejection", (reason: Error, promise: Promise) => { 14 | throw reason; 15 | }); 16 | -------------------------------------------------------------------------------- /packages/backend/src/common/errors/socket/socket.errors.ts: -------------------------------------------------------------------------------- 1 | import { Status } from "@core/errors/status.codes"; 2 | import { ErrorMetadata } from "@backend/common/types/error.types"; 3 | 4 | interface SocketErrors { 5 | InvalidSocketId: ErrorMetadata; 6 | ServerNotReady: ErrorMetadata; 7 | } 8 | 9 | export const SocketError: SocketErrors = { 10 | InvalidSocketId: { 11 | description: "Invalid socket id", 12 | status: Status.BAD_REQUEST, 13 | isOperational: true, 14 | }, 15 | ServerNotReady: { 16 | description: 17 | "WebSocket server not ready (Did you forget to initialize it?)", 18 | status: Status.INTERNAL_SERVER, 19 | isOperational: false, 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /packages/backend/src/common/errors/user/user.errors.ts: -------------------------------------------------------------------------------- 1 | import { Status } from "@core/errors/status.codes"; 2 | import { ErrorMetadata } from "@backend/common/types/error.types"; 3 | 4 | interface UserErrors { 5 | InvalidValue: ErrorMetadata; 6 | MissingGoogleUserField: ErrorMetadata; 7 | MissingUserIdField: ErrorMetadata; 8 | UserNotFound: ErrorMetadata; 9 | } 10 | 11 | export const UserError: UserErrors = { 12 | InvalidValue: { 13 | description: "User has an invalid value", 14 | status: Status.BAD_REQUEST, 15 | isOperational: true, 16 | }, 17 | MissingGoogleUserField: { 18 | description: "Email field is missing from the Google user object", 19 | status: Status.NOT_FOUND, 20 | isOperational: true, 21 | }, 22 | MissingUserIdField: { 23 | description: "Failed to access the userId", 24 | status: Status.BAD_REQUEST, 25 | isOperational: true, 26 | }, 27 | UserNotFound: { 28 | description: "User not found", 29 | status: Status.NOT_FOUND, 30 | isOperational: true, 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /packages/backend/src/common/errors/waitlist/waitlist.errors.ts: -------------------------------------------------------------------------------- 1 | import { Status } from "@core/errors/status.codes"; 2 | import { ErrorMetadata } from "@backend/common/types/error.types"; 3 | 4 | interface WaitlistErrors { 5 | DuplicateEmail: ErrorMetadata; 6 | NotOnWaitlist: ErrorMetadata; 7 | } 8 | 9 | export const WaitlistError: WaitlistErrors = { 10 | DuplicateEmail: { 11 | description: "Email is already on waitlist", 12 | status: Status.BAD_REQUEST, 13 | isOperational: true, 14 | }, 15 | NotOnWaitlist: { 16 | description: "Email is not on waitlist", 17 | status: Status.NOT_FOUND, 18 | isOperational: true, 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /packages/backend/src/common/helpers/common.util.test.ts: -------------------------------------------------------------------------------- 1 | import { yearsAgo } from "./common.util"; 2 | 3 | test("yearsAgo is a Date object", () => { 4 | const twoYrs = yearsAgo(2); 5 | expect(() => twoYrs.toISOString()).not.toThrow(Error); 6 | }); 7 | -------------------------------------------------------------------------------- /packages/backend/src/common/helpers/common.util.ts: -------------------------------------------------------------------------------- 1 | export const yearsAgo = (numYears: number) => { 2 | return new Date(new Date().setFullYear(new Date().getFullYear() - numYears)); 3 | }; 4 | -------------------------------------------------------------------------------- /packages/backend/src/common/helpers/mongo.utils.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from "mongodb"; 2 | 3 | export const getIdFilter = (key: string, value: string) => { 4 | if (key === "_id") { 5 | return { _id: new ObjectId(value) }; 6 | } 7 | return { [key]: value }; 8 | }; 9 | -------------------------------------------------------------------------------- /packages/backend/src/common/middleware/cors.middleware.ts: -------------------------------------------------------------------------------- 1 | import cors from "cors"; 2 | import { ENV } from "../constants/env.constants"; 3 | 4 | const corsWhitelist = cors({ 5 | credentials: true, 6 | origin: function (origin, callback) { 7 | if (!origin) return callback(null, true); 8 | 9 | if (ENV.ORIGINS_ALLOWED.indexOf(origin) === -1) { 10 | const msg = `The CORS policy for this site does not allow access from ${origin}`; 11 | return callback(new Error(msg), false); 12 | } 13 | return callback(null, true); 14 | }, 15 | }); 16 | 17 | export default corsWhitelist; 18 | -------------------------------------------------------------------------------- /packages/backend/src/common/middleware/mongo.validation.middleware.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { ObjectId } from "mongodb"; 3 | import { SessionRequest } from "supertokens-node/framework/express"; 4 | import { DbError } from "@backend/common/errors/db/db.errors"; 5 | import { error } from "@backend/common/errors/handlers/error.handler"; 6 | 7 | export const validateIdParam = ( 8 | req: SessionRequest, 9 | res: express.Response, 10 | next: express.NextFunction, 11 | ) => { 12 | const idParam = req.params["id"] as string; 13 | 14 | if (!ObjectId.isValid(idParam)) { 15 | const err = error(DbError.InvalidId, "Request Failed"); 16 | res.status(err.statusCode).json({ error: err }); 17 | } 18 | 19 | next(); 20 | }; 21 | -------------------------------------------------------------------------------- /packages/backend/src/common/services/gcal/gcal.util.test.ts: -------------------------------------------------------------------------------- 1 | import { invalidGrant400Error } from "../../../__tests__/mocks.gcal/errors/error.google.invalidGrant"; 2 | import { invalidValueError } from "../../../__tests__/mocks.gcal/errors/error.google.invalidValue"; 3 | import { invalidSyncTokenError } from "../../../__tests__/mocks.gcal/errors/error.invalidSyncToken"; 4 | import { 5 | getEmailFromUrl, 6 | isFullSyncRequired, 7 | isInvalidGoogleToken, 8 | isInvalidValue, 9 | } from "./gcal.utils"; 10 | 11 | describe("Google Error Parsing", () => { 12 | it("recognizes invalid sync token error", () => { 13 | expect(isFullSyncRequired(invalidSyncTokenError)).toBe(true); 14 | }); 15 | it("recognizes invalid (sync)value error", () => { 16 | expect(isInvalidValue(invalidValueError)).toBe(true); 17 | }); 18 | it("recognizes expired refresh token", () => { 19 | expect(isInvalidGoogleToken(invalidGrant400Error)).toBe(true); 20 | }); 21 | }); 22 | 23 | describe("Gaxios response parsing", () => { 24 | it("returns email with @", () => { 25 | const url = 26 | "https://www.googleapis.com/calendar/v3/calendars/foo%40bar.com/events?syncToken=!!!!!!!!!!!!!!!jqgYQwNWZ_QyyHyycChpiZHJvaGl1aHyyyyyyymxvZmMwaXZodjN2ZxoMCO7Xj6sGEICGncADwD4B"; 27 | expect(getEmailFromUrl(url)).toBe("foo@bar.com"); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /packages/backend/src/common/types/backend.event.types.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from "mongodb"; 2 | import { z } from "zod"; 3 | import { CoreEventSchema } from "@core/types/event.types"; 4 | 5 | const zObjectId = z.instanceof(ObjectId); 6 | 7 | /** 8 | * Schema for internal events that match how they 9 | * are saved to the DB 10 | */ 11 | export const Schema_Event_API = CoreEventSchema.extend({ 12 | _id: zObjectId.optional(), 13 | }); 14 | 15 | export type Event_API = z.infer; 16 | -------------------------------------------------------------------------------- /packages/backend/src/common/types/error.types.ts: -------------------------------------------------------------------------------- 1 | export interface CompassError extends Error { 2 | name: string; 3 | result?: string; 4 | stack?: string; 5 | status?: number; 6 | } 7 | 8 | export interface ErrorMetadata { 9 | description: string; 10 | isOperational: boolean; 11 | status: number; 12 | } 13 | 14 | export interface Info_Error { 15 | name?: string; 16 | message: string; 17 | stack?: string; 18 | } 19 | -------------------------------------------------------------------------------- /packages/backend/src/common/types/express.types.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { Request, Response } from "express"; 3 | import { SessionRequest } from "supertokens-node/framework/express"; 4 | 5 | export interface ReqBody extends Request { 6 | body: T; 7 | } 8 | 9 | export interface Res_Promise extends express.Response { 10 | promise: (p: Promise | (() => unknown) | any) => Express.Response; 11 | } 12 | 13 | export interface SReqBody extends SessionRequest { 14 | body: T; 15 | } 16 | 17 | export interface SessionResponse extends Response { 18 | req: SessionRequest; 19 | } 20 | -------------------------------------------------------------------------------- /packages/backend/src/common/types/sync.types.ts: -------------------------------------------------------------------------------- 1 | import { DeleteResult, UpdateResult } from "mongodb"; 2 | import { Result_Watch_Stop } from "@core/types/sync.types"; 3 | 4 | export interface Summary_Resync { 5 | _delete: { 6 | calendarlist?: UpdateResult; 7 | events?: DeleteResult; 8 | eventWatches?: Result_Watch_Stop; 9 | sync?: UpdateResult; 10 | }; 11 | recreate: { 12 | calendarlist?: UpdateResult; 13 | eventWatches?: UpdateResult[]; 14 | events?: "success"; 15 | sync?: UpdateResult; 16 | }; 17 | revoke?: { 18 | sessionsRevoked?: number; 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /packages/backend/src/common/validators/validate.event.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Event_API, 3 | Schema_Event_API, 4 | } from "@backend/common/types/backend.event.types"; 5 | import { safeValidate } from "./validate"; 6 | 7 | export const validateEventSafely = (event: Event_API) => { 8 | const result = safeValidate(Schema_Event_API, event); 9 | return result; 10 | }; 11 | -------------------------------------------------------------------------------- /packages/backend/src/common/validators/validate.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const safeValidate = (schema: z.ZodType, data: unknown) => { 4 | const result = schema.safeParse(data); 5 | 6 | return result; 7 | }; 8 | -------------------------------------------------------------------------------- /packages/backend/src/email/email.types.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { SubscriberStateSchema } from "@core/types/email/email.types"; 3 | 4 | export const RequestBody_AddTagToSubscriberSchema = z.object({ 5 | email_address: z.string().email(), 6 | }); 7 | 8 | export type RequestBody_AddTagToSubscriber = z.infer< 9 | typeof RequestBody_AddTagToSubscriberSchema 10 | >; 11 | 12 | export const Response_UpsertSubscriberSchema = z.object({ 13 | subscriber: z.object({ 14 | id: z.number().int(), 15 | first_name: z.string(), 16 | email_address: z.string().email(), 17 | state: SubscriberStateSchema, 18 | created_at: z.string().datetime(), 19 | fields: z.object({}).optional(), 20 | }), 21 | }); 22 | 23 | export type Response_UpsertSubscriber = z.infer< 24 | typeof Response_UpsertSubscriberSchema 25 | >; 26 | 27 | export const Response_TagSubscriberSchema = z.object({ 28 | subscriber: z.object({ 29 | id: z.number().int(), 30 | first_name: z.string(), 31 | email_address: z.string().email(), 32 | state: SubscriberStateSchema, 33 | created_at: z.string().datetime(), 34 | tagged_at: z.string().datetime(), 35 | fields: z.object({}).optional(), 36 | }), 37 | }); 38 | 39 | export type Response_TagSubscriber = z.infer< 40 | typeof Response_TagSubscriberSchema 41 | >; 42 | -------------------------------------------------------------------------------- /packages/backend/src/event/queries/event.queries.ts: -------------------------------------------------------------------------------- 1 | import { AnyBulkWriteOperation, WithId } from "mongodb"; 2 | import { Payload_Order, Schema_Event } from "@core/types/event.types"; 3 | import { Collections } from "@backend/common/constants/collections"; 4 | import { getIdFilter } from "@backend/common/helpers/mongo.utils"; 5 | import mongoService from "@backend/common/services/mongo.service"; 6 | 7 | export type Ids_Event = "_id" | "gEventId" | "gRecurringEventId"; 8 | 9 | /** 10 | * DB operations for Compass's Event collection, focused 11 | * on primitive operations 12 | */ 13 | 14 | export const findCompassEventBy = async (key: Ids_Event, value: string) => { 15 | const filter = getIdFilter(key, value); 16 | 17 | const event = (await mongoService.db 18 | .collection(Collections.EVENT) 19 | .findOne(filter)) as unknown as WithId; 20 | 21 | return { eventExists: event !== null, event }; 22 | }; 23 | 24 | export const reorderEvents = async (userId: string, order: Payload_Order[]) => { 25 | const ops: AnyBulkWriteOperation[] = []; 26 | order.forEach((item) => { 27 | ops.push({ 28 | updateOne: { 29 | filter: { _id: mongoService.objectId(item._id), user: userId }, 30 | update: { $set: { order: item.order } }, 31 | }, 32 | }); 33 | }); 34 | 35 | const result = await mongoService.event.bulkWrite(ops); 36 | return result; 37 | }; 38 | -------------------------------------------------------------------------------- /packages/backend/src/event/services/recur/queries/event.recur.queries.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from "mongodb"; 2 | import { Collections } from "@backend/common/constants/collections"; 3 | import mongoService from "@backend/common/services/mongo.service"; 4 | 5 | /** 6 | * DB operations for Compass's Event collection, focused 7 | * on recurring event operations 8 | */ 9 | 10 | export const deleteInstances = async (userId: string, baseId: string) => { 11 | if (typeof baseId !== "string") { 12 | throw new Error("Invalid baseId"); 13 | } 14 | const response = await mongoService.db 15 | .collection(Collections.EVENT) 16 | .deleteMany({ 17 | user: userId, 18 | _id: { $ne: new ObjectId(baseId) }, 19 | "recurrence.eventId": { $eq: baseId }, 20 | }); 21 | 22 | return response; 23 | }; 24 | -------------------------------------------------------------------------------- /packages/backend/src/event/services/recur/recur.month.test.ts: -------------------------------------------------------------------------------- 1 | import { Schema_Event_Core } from "@core/types/event.types"; 2 | import { RRULE } from "../../../../../core/src/constants/core.constants"; 3 | import { assembleInstances } from "./util/recur.util"; 4 | 5 | describe("Event Recurrence: Month", () => { 6 | it("uses first and last of month", () => { 7 | const events = assembleInstances({ 8 | startDate: "2023-10-01", 9 | endDate: "2023-10-31", 10 | recurrence: { 11 | rule: [RRULE.MONTH], 12 | }, 13 | } as Schema_Event_Core); 14 | 15 | expect(events[0].startDate).toBe("2023-10-01"); 16 | expect(events[0].endDate).toBe("2023-10-31"); 17 | expect(events[1].startDate).toBe("2023-11-01"); 18 | expect(events[1].endDate).toBe("2023-11-30"); 19 | expect(events[2].startDate).toBe("2023-12-01"); 20 | expect(events[2].endDate).toBe("2023-12-31"); 21 | expect(events[3].startDate).toBe("2024-01-01"); 22 | expect(events[3].endDate).toBe("2024-01-31"); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /packages/backend/src/event/services/recur/recur.types.ts: -------------------------------------------------------------------------------- 1 | import { gSchema$Event } from "@core/types/gcal"; 2 | 3 | export type Scenario = 4 | | "REGULAR_EVENT" 5 | | "NEW_RECURRING" 6 | | "SINGLE_INSTANCE" 7 | | "THIS_AND_FUTURE" 8 | | "ALL_INSTANCES"; 9 | 10 | export interface ScenarioAnalysis { 11 | scenario: Scenario; 12 | baseEvent?: gSchema$Event; 13 | modifiedInstance?: gSchema$Event; 14 | newBaseEvent?: gSchema$Event; 15 | } 16 | -------------------------------------------------------------------------------- /packages/backend/src/event/services/recur/util/recur.util.test.ts: -------------------------------------------------------------------------------- 1 | import { createMockBaseEvent } from "@core/util/test/ccal.event.factory"; 2 | import { stripBaseProps } from "./recur.util"; 3 | 4 | describe("stripBaseProps", () => { 5 | it("should remove props that are not allowed to change", () => { 6 | const baseEvent = createMockBaseEvent(); 7 | const strippedEvent = stripBaseProps(baseEvent); 8 | const prohibitedProps = [ 9 | "_id", 10 | "gEventId", 11 | "startDate", 12 | "endDate", 13 | "order", 14 | "recurrence", 15 | "user", 16 | "updatedAt", 17 | ]; 18 | 19 | const remainingKeys = Object.keys(strippedEvent); 20 | expect(remainingKeys).not.toContain(prohibitedProps); 21 | expect(1).toBe(1); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /packages/backend/src/init.ts: -------------------------------------------------------------------------------- 1 | // sort-imports-ignore 2 | import dotenv from "dotenv"; 3 | import moduleAlias from "module-alias"; 4 | import path from "path"; 5 | moduleAlias.addAliases({ 6 | "@backend": `${__dirname}`, 7 | "@core": `${path.resolve(__dirname, "../../core/src")}`, 8 | }); 9 | // eslint-disable-next-line prettier/prettier 10 | import { Logger } from "@core/logger/winston.logger"; 11 | 12 | const dotenvResult = dotenv.config(); 13 | if (dotenvResult.error) { 14 | throw dotenvResult.error; 15 | } 16 | 17 | export const logger = Logger("app:root"); 18 | -------------------------------------------------------------------------------- /packages/backend/src/priority/middleware/priority.middleware.ts: -------------------------------------------------------------------------------- 1 | class PriorityMiddleware {} 2 | export default new PriorityMiddleware(); 3 | -------------------------------------------------------------------------------- /packages/backend/src/priority/priority.routes.config.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { verifySession } from "supertokens-node/recipe/session/framework/express"; 3 | import { CommonRoutesConfig } from "@backend/common/common.routes.config"; 4 | import { validateIdParam } from "@backend/common/middleware/mongo.validation.middleware"; 5 | import PriorityController from "./controllers/priority.controller"; 6 | 7 | export class PriorityRoutes extends CommonRoutesConfig { 8 | constructor(app: express.Application) { 9 | super(app, "PriorityRoutes"); 10 | } 11 | 12 | configureRoutes() { 13 | this.app 14 | .route(`/api/priority`) 15 | .all(verifySession()) 16 | //@ts-ignore 17 | .get(PriorityController.readAll) 18 | //@ts-ignore 19 | .post(PriorityController.create); 20 | 21 | this.app 22 | .route(`/api/priority/:id`) 23 | .all([verifySession(), validateIdParam]) 24 | //@ts-ignore 25 | .get(PriorityController.readById) 26 | //@ts-ignore 27 | .put(PriorityController.update) 28 | //@ts-ignore 29 | .delete(PriorityController.delete); 30 | 31 | return this.app; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/backend/src/priority/services/priority.service.helpers.test.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from "mongodb"; 2 | import { InsertedIds } from "../../../../core/src/types/mongo.types"; 3 | import { 4 | PriorityReq, 5 | Schema_Priority, 6 | } from "../../../../core/src/types/priority.types"; 7 | import { mapPriorityData } from "./priority.service.helpers"; 8 | 9 | test("Priority ids mapped in correct order", () => { 10 | const insertedIds: InsertedIds = { 11 | [0]: new ObjectId(), 12 | [1]: new ObjectId(), 13 | [2]: new ObjectId(), 14 | }; 15 | const priorityData: PriorityReq[] = [ 16 | { name: "foo", color: "bar" }, 17 | { name: "boz", color: "bim" }, 18 | { name: "mooo", color: "koy" }, 19 | ]; 20 | 21 | const priorities: Schema_Priority[] = mapPriorityData( 22 | insertedIds, 23 | priorityData, 24 | "user123", 25 | ); 26 | 27 | expect(priorities[0]._id).toEqual(insertedIds[0].toString()); 28 | expect(priorities[1]._id).toEqual(insertedIds[1].toString()); 29 | expect(priorities[2]._id).toEqual(insertedIds[2].toString()); 30 | }); 31 | -------------------------------------------------------------------------------- /packages/backend/src/priority/services/priority.service.helpers.ts: -------------------------------------------------------------------------------- 1 | import { InsertedIds } from "@core/types/mongo.types"; 2 | import { PriorityReq, Schema_Priority } from "@core/types/priority.types"; 3 | 4 | // documents inserted in order by default, so mapping by 5 | // key order is safe 6 | export const mapPriorityData = ( 7 | newIds: InsertedIds, 8 | data: PriorityReq[], 9 | userId: string, 10 | ): Schema_Priority[] => { 11 | const priorities: Schema_Priority[] = []; 12 | for (const [key, id] of Object.entries(newIds)) { 13 | const i = parseInt(key); 14 | priorities.push({ 15 | _id: id.toString(), 16 | user: userId, 17 | //@ts-ignore 18 | name: data[i].name, 19 | //@ts-ignore 20 | color: data[i].color, 21 | }); 22 | } 23 | return priorities; 24 | }; 25 | -------------------------------------------------------------------------------- /packages/backend/src/servers/websocket/websocket.util.ts: -------------------------------------------------------------------------------- 1 | import { Server as HttpServer } from "http"; 2 | import { type AddressInfo } from "node:net"; 3 | import { BaseError } from "@core/errors/errors.base"; 4 | import { Logger } from "@core/logger/winston.logger"; 5 | 6 | const logger = Logger("app:websocket.util"); 7 | 8 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 9 | type HandlerFunction = (...args: T) => R | Promise; 10 | 11 | export const getServerUri = (httpServer: HttpServer) => { 12 | const port = (httpServer.address() as AddressInfo).port; 13 | return `http://localhost:${port}`; 14 | }; 15 | 16 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 17 | export const handleWsError = ( 18 | handler: HandlerFunction, 19 | ) => { 20 | const handleError = (err: BaseError) => { 21 | logger.error("WebSocket Error:\n\t", err); 22 | throw err; 23 | }; 24 | 25 | return (...args: T): R | void | Promise => { 26 | try { 27 | const ret = handler(...args); 28 | const isHandlerAsync = 29 | ret && typeof (ret as Promise).catch === "function"; 30 | if (isHandlerAsync) { 31 | (ret as Promise).catch(handleError); 32 | } 33 | return ret; 34 | } catch (e) { 35 | // sync handler 36 | handleError(e as BaseError); 37 | } 38 | }; 39 | }; 40 | -------------------------------------------------------------------------------- /packages/backend/src/sync/services/import/sync.import.types.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from "mongodb"; 2 | import { Schema_Event } from "@core/types/event.types"; 3 | import { 4 | gSchema$Event, 5 | gSchema$EventBase, 6 | gSchema$EventInstance, 7 | } from "@core/types/gcal"; 8 | 9 | export interface EventsToModify { 10 | toUpdate: Schema_Event[]; 11 | toDelete: string[]; 12 | } 13 | 14 | export type Callback_EventProcessor = ( 15 | event: gSchema$Event | gSchema$EventBase | gSchema$EventInstance, 16 | sharedState: Map_Recurrences, 17 | ) => boolean; // Returns true if event should be saved 18 | 19 | export interface Map_Recurrences { 20 | baseEventStartTimes: Map; 21 | processedEventIdsPass1: Set; 22 | baseEventMap: Map; 23 | } 24 | -------------------------------------------------------------------------------- /packages/backend/src/sync/services/notify/gcal.notification.util.ts: -------------------------------------------------------------------------------- 1 | import { error } from "@backend/common/errors/handlers/error.handler"; 2 | import { SyncError } from "@backend/common/errors/sync/sync.errors"; 3 | import { getSync } from "@backend/sync/util/sync.queries"; 4 | 5 | /** 6 | * Get the user ID and Google Calendar ID from a sync payload 7 | */ 8 | export const getIdsFromSyncPayload = async ( 9 | channelId: string, 10 | resourceId: string, 11 | ) => { 12 | // Get the sync record to find the calendar ID 13 | const sync = await getSync({ resourceId }); 14 | if (!sync) { 15 | throw error( 16 | SyncError.NoSyncRecordForUser, 17 | `Notification not handled because no sync record found for resource ${resourceId}`, 18 | ); 19 | } 20 | const userId = sync.user; 21 | 22 | // Find the calendar sync record 23 | const calendarSync = sync.google?.events?.find( 24 | (event) => event.channelId === channelId, 25 | ); 26 | if (!calendarSync?.gCalendarId) { 27 | throw error( 28 | SyncError.NoSyncRecordForUser, 29 | `Notification not handled because no calendar found for channel ${channelId}`, 30 | ); 31 | } 32 | 33 | return { 34 | userId, 35 | gCalendarId: calendarSync.gCalendarId, 36 | }; 37 | }; 38 | -------------------------------------------------------------------------------- /packages/backend/src/sync/services/watch/sync.watch.ts: -------------------------------------------------------------------------------- 1 | import { gCalendar } from "@core/types/gcal"; 2 | import { Schema_Sync } from "@core/types/sync.types"; 3 | import syncService from "../sync.service"; 4 | 5 | export const assembleEventWatchPayloads = ( 6 | sync: Schema_Sync, 7 | gCalendarIds: string[], 8 | ) => { 9 | const watchPayloads = gCalendarIds.map((gCalId) => { 10 | const match = sync?.google.events.find((es) => es.gCalendarId === gCalId); 11 | const eventNextSyncToken = match?.nextSyncToken; 12 | if (eventNextSyncToken) { 13 | return { gCalId, nextSyncToken: eventNextSyncToken }; 14 | } 15 | 16 | return { gCalId }; 17 | }); 18 | 19 | return watchPayloads; 20 | }; 21 | 22 | export const watchEventsByGcalIds = async ( 23 | userId: string, 24 | gCalendarIds: string[], 25 | gcal: gCalendar, 26 | ) => { 27 | const watchGcalEvents = gCalendarIds.map((gCalendarId) => 28 | syncService.startWatchingGcalEvents(userId, { gCalendarId }, gcal), 29 | ); 30 | 31 | const results = await Promise.all(watchGcalEvents); 32 | return results; 33 | }; 34 | -------------------------------------------------------------------------------- /packages/backend/src/sync/sync.types.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from "mongodb"; 2 | import { Categories_Recurrence, Event_Core } from "@core/types/event.types"; 3 | 4 | export type Event_Core_WithObjectId = Omit & { 5 | _id?: ObjectId; 6 | }; 7 | export type Summary_Sync = { 8 | summary: "PROCESSED" | "IGNORED"; 9 | changes: Change_Gcal[]; 10 | }; 11 | 12 | export type Operation_Sync = "DELETED" | "UPSERTED" | null; 13 | export type Change_Gcal = { 14 | title: string; 15 | category: Categories_Recurrence; 16 | operation: Operation_Sync; 17 | }; 18 | -------------------------------------------------------------------------------- /packages/backend/src/user/controllers/user.controller.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SwitchbackTech/compass/b4d777dbb5bd83406b8d0ae2be5fd358917a09ac/packages/backend/src/user/controllers/user.controller.ts -------------------------------------------------------------------------------- /packages/backend/src/user/queries/user.queries.ts: -------------------------------------------------------------------------------- 1 | import { getIdFilter } from "@backend/common/helpers/mongo.utils"; 2 | import mongoService from "@backend/common/services/mongo.service"; 3 | 4 | type Ids_User = "email" | "_id" | "google.googleId"; 5 | 6 | export const findCompassUserBy = async (key: Ids_User, value: string) => { 7 | const filter = getIdFilter(key, value); 8 | const user = await mongoService.user.findOne(filter); 9 | 10 | return user; 11 | }; 12 | 13 | export const findCompassUsersBy = async (key: Ids_User, value: string) => { 14 | const filter = getIdFilter(key, value); 15 | 16 | const users = await mongoService.user.find(filter).toArray(); 17 | 18 | return users; 19 | }; 20 | 21 | export const updateGoogleRefreshToken = async ( 22 | id: string, 23 | gRefreshToken: string, 24 | ) => { 25 | const filter = getIdFilter("_id", id); 26 | const result = await mongoService.user.findOneAndUpdate(filter, { 27 | $set: { "google.gRefreshToken": gRefreshToken }, 28 | }); 29 | 30 | return result; 31 | }; 32 | -------------------------------------------------------------------------------- /packages/backend/src/user/types/user.types.ts: -------------------------------------------------------------------------------- 1 | export interface Summary_Delete { 2 | calendarlist?: number; 3 | events?: number; 4 | eventWatches?: number; 5 | priorities?: number; 6 | syncs?: number; 7 | user?: number; 8 | sessions?: number; 9 | } 10 | -------------------------------------------------------------------------------- /packages/backend/src/user/user.routes.config.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SwitchbackTech/compass/b4d777dbb5bd83406b8d0ae2be5fd358917a09ac/packages/backend/src/user/user.routes.config.ts -------------------------------------------------------------------------------- /packages/backend/src/waitlist/service/waitlist.service-check.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | cleanupCollections, 3 | cleanupTestMongo, 4 | setupTestDb, 5 | } from "@backend/__tests__/helpers/mock.db.setup"; 6 | import WaitlistService from "./waitlist.service"; 7 | import { answer } from "./waitlist.service.test-setup"; 8 | 9 | describe("isOnWaitlist", () => { 10 | let setup: Awaited>; 11 | 12 | beforeAll(async () => { 13 | setup = await setupTestDb(); 14 | }); 15 | 16 | beforeEach(async () => { 17 | await cleanupCollections(setup.db); 18 | }); 19 | 20 | afterAll(async () => { 21 | await cleanupTestMongo(setup); 22 | }); 23 | 24 | it("should return false if email is not waitlisted", async () => { 25 | const result = await WaitlistService.isOnWaitlist(answer.email); 26 | expect(result).toBe(false); 27 | }); 28 | 29 | it("should return true if email is waitlisted", async () => { 30 | await WaitlistService.addToWaitlist(answer.email, answer); 31 | 32 | const result = await WaitlistService.isOnWaitlist(answer.email); 33 | 34 | expect(result).toBe(true); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /packages/backend/src/waitlist/service/waitlist.service-getAllWaitlisted.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | cleanupCollections, 3 | cleanupTestMongo, 4 | setupTestDb, 5 | } from "@backend/__tests__/helpers/mock.db.setup"; 6 | import WaitlistService from "./waitlist.service"; 7 | import { answer } from "./waitlist.service.test-setup"; 8 | 9 | describe("getAllWaitlisted", () => { 10 | let setup: Awaited>; 11 | 12 | beforeAll(async () => { 13 | setup = await setupTestDb(); 14 | }); 15 | 16 | beforeEach(async () => { 17 | await cleanupCollections(setup.db); 18 | }); 19 | 20 | afterAll(async () => { 21 | await cleanupTestMongo(setup); 22 | }); 23 | 24 | it("should return all waitlisted records", async () => { 25 | await WaitlistService.addToWaitlist(answer.email, answer); 26 | 27 | const records = await WaitlistService.getAllWaitlisted(); 28 | 29 | expect(records.length).toBeGreaterThanOrEqual(1); 30 | const allWaitlisted = records.every((r) => r.status === "waitlisted"); 31 | expect(allWaitlisted).toBe(true); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /packages/backend/src/waitlist/waitlist.routes.config.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { CommonRoutesConfig } from "@backend/common/common.routes.config"; 3 | import { WaitlistController } from "./controller/waitlist.controller"; 4 | 5 | export class WaitlistRoutes extends CommonRoutesConfig { 6 | constructor(app: express.Application) { 7 | super(app, "WaitlistRoutes"); 8 | } 9 | 10 | configureRoutes() { 11 | this.app 12 | .route("/api/waitlist") 13 | .post(WaitlistController.addToWaitlist) 14 | .get(WaitlistController.status); 15 | return this.app; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "ts-node": { 4 | "files": true, 5 | // Skip typechecking for faster development 6 | "transpileOnly": true, 7 | "compilerOptions": { 8 | // compilerOptions specified here will override those declared below, 9 | // but *only* in ts-node. Useful if you want ts-node and tsc to use 10 | // different options with a single tsconfig.json. 11 | } 12 | }, 13 | "compilerOptions": { 14 | "sourceMap": false, 15 | "inlineSourceMap": true 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@compass/core", 3 | "description": "Core module shared across packages", 4 | "license": "MIT", 5 | "version": "1.0.0", 6 | "private": true, 7 | "main": "", 8 | "scripts": { 9 | "precommit": "exit", 10 | "lint-fix": "exit" 11 | }, 12 | "devDependencies": { 13 | "@types/tinycolor2": "^1.4.6", 14 | "typescript": "^5.1.6", 15 | "@faker-js/faker": "^9.6.0" 16 | }, 17 | "dependencies": { 18 | "tinycolor2": "^1.6.0", 19 | "winston": "^3.8.1" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/core/src/__mocks__/v1/calendarlist/calendarlist.ts: -------------------------------------------------------------------------------- 1 | export const compassCalendarList = { 2 | user: "auserid", 3 | google: { 4 | nextSyncToken: "calendarListSyncToken", 5 | items: [ 6 | { 7 | id: "27th kdkdkdkkd", 8 | summary: "My Primary", 9 | description: "hardCoded testing", 10 | sync: { 11 | channelId: "channel1", 12 | resourceId: "resource1", 13 | nextSyncToken: "883838jjjj", 14 | expiration: "somestring", 15 | }, 16 | }, 17 | { 18 | primary: true, // this doesnt appear for all non-primary cals 19 | id: "27th iiiiiii", 20 | summary: "27th work", 21 | description: "just work stuff here", 22 | sync: { 23 | channelId: "channel2", 24 | resourceId: "resource2", 25 | nextSyncToken: "bnamske", 26 | expiration: "string2", 27 | }, 28 | }, 29 | ], 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /packages/core/src/__mocks__/v1/events/events.someday.1.ts: -------------------------------------------------------------------------------- 1 | export const mockEventSetSomeday1 = [ 2 | { 3 | _id: "id1a", 4 | user: "user1", 5 | title: "Multi-Month 1", 6 | startDate: "2023-05-28", 7 | endDate: "2023-06-03", 8 | isSomeday: true, 9 | }, 10 | { 11 | _id: "id1b", 12 | user: "user1", 13 | title: "Multi-Month 2", 14 | startDate: "2023-01-28", 15 | endDate: "2023-05-28", 16 | isSomeday: true, 17 | }, 18 | { 19 | _id: "id2", 20 | user: "user1", 21 | title: "First Sunday of New Month", 22 | startDate: "2023-06-04", 23 | endDate: "2023-06-04", 24 | isSomeday: true, 25 | }, 26 | { 27 | _id: "id3", 28 | user: "user1", 29 | title: "Distant Future", 30 | startDate: "2025-01-01", 31 | endDate: "2023-01-06", 32 | isSomeday: true, 33 | }, 34 | { 35 | _id: "id4", 36 | user: "user1", 37 | title: "Distant Past", 38 | startDate: "1999-01-01", 39 | endDate: "1999-01-06", 40 | isSomeday: true, 41 | }, 42 | { 43 | _id: "id5", 44 | user: "user1", 45 | title: "Beginning of Month", 46 | startDate: "2023-10-01", 47 | endDate: "2023-10-07", 48 | isSomeday: true, 49 | }, 50 | ]; 51 | -------------------------------------------------------------------------------- /packages/core/src/__mocks__/v1/events/gcal/gcal.allday.ts: -------------------------------------------------------------------------------- 1 | export const allday = [ 2 | { 3 | kind: "calendar#event", 4 | etag: '"3291861297900000"', 5 | id: "1evdi8c1s5knlt5ofhncl654u9", 6 | status: "confirmed", 7 | htmlLink: 8 | "https://www.google.com/calendar/event?eid=MWV2ZGk4YzFzNWtubHQ1b2ZobmNsNjU0dTkgdHlAc3dpdGNoYmFjay50ZWNo", 9 | created: "2022-02-27T02:57:28.000Z", 10 | updated: "2022-02-27T02:57:28.950Z", 11 | summary: "Feb 22", 12 | creator: { 13 | email: "foo@gmail.com", 14 | self: true, 15 | }, 16 | organizer: { 17 | email: "foo@gmail.com", 18 | self: true, 19 | }, 20 | start: { 21 | date: "2022-02-22", 22 | }, 23 | end: { 24 | date: "2022-02-23", 25 | }, 26 | transparency: "transparent", 27 | iCalUID: "1evdi8c1s5knlt5ofhncl654u9@google.com", 28 | sequence: 0, 29 | private: { 30 | origin: "googleimport", 31 | priority: "relationships", 32 | }, 33 | 34 | reminders: { 35 | useDefault: false, 36 | }, 37 | eventType: "default", 38 | }, 39 | ]; 40 | -------------------------------------------------------------------------------- /packages/core/src/__mocks__/v1/events/gcal/gcal.cancelled.ts: -------------------------------------------------------------------------------- 1 | export const cancelled = [ 2 | { 3 | kind: "calendar#event", 4 | etag: '"2734519593376000"', 5 | id: "tlf9q8uk5vjl2i2868q36dpi28_20130508T220000Z", 6 | status: "cancelled", 7 | recurringEventId: "tlf9q8uk5vjl2i2868q36dpi28", 8 | originalStartTime: { 9 | dateTime: "2013-05-08T16:00:00-06:00", 10 | }, 11 | }, 12 | { 13 | kind: "calendar#event", 14 | etag: '"3279003328426000"', 15 | id: "0cu25g99pfkhlfarupevcjc297_20211123T170000Z", 16 | status: "cancelled", 17 | }, 18 | ]; 19 | -------------------------------------------------------------------------------- /packages/core/src/__mocks__/v1/events/gcal/gcal.event.ts: -------------------------------------------------------------------------------- 1 | import { allday } from "./gcal.allday"; 2 | import { cancelled } from "./gcal.cancelled"; 3 | import { recurring } from "./gcal.recurring"; 4 | import { timed } from "./gcal.timed"; 5 | 6 | export const gcalEvents = { 7 | kind: "calendar#events", 8 | etag: '"p320cj0e0m6hf80g"', 9 | summary: "Miscellaneous Calendar", 10 | updated: "2021-11-18T17:00:20.816Z", 11 | timeZone: "America/Denver", 12 | accessRole: "owner", 13 | defaultReminders: [ 14 | { 15 | method: "email", 16 | minutes: 30, 17 | }, 18 | { 19 | method: "popup", 20 | minutes: 30, 21 | }, 22 | ], 23 | nextPageToken: 24 | "CigKGnJqMDBhZ2owNHQ0M2hyZm1kcjFhM3VkcTE4GAEggIDAjP_KsfQTGg8IABIAGIDJgcCxovQCIAEiBwgCEOvBsRw=", 25 | items: [...cancelled, ...timed, ...allday, ...recurring], 26 | }; 27 | -------------------------------------------------------------------------------- /packages/core/src/constants/date.constants.ts: -------------------------------------------------------------------------------- 1 | export const DAY_COMPACT = "ddd"; // Mon 2 | export const DAY_HOUR_MIN_M = "ddd h:mma"; // Mon 1:15am 3 | 4 | export const HOURS_AM_SHORT_FORMAT = "h A"; 5 | export const HOURS_AM_FORMAT = "h:mm A"; 6 | export const HOURS_MINUTES_FORMAT = "HH:mm"; 7 | 8 | export const MONTH_DAY_COMPACT_FORMAT = "MMM DD"; 9 | export const MONTH_DAY_YEAR = "M-D-YYYY"; 10 | export const MONTH_YEAR_COMPACT_FORMAT = "M/YYYY"; 11 | 12 | export const YEAR_MONTH_FORMAT = "YYYY-MM"; 13 | export const YEAR_MONTH_DAY_FORMAT = "YYYY-MM-DD"; 14 | export const YEAR_MONTH_DAY_COMPACT_FORMAT = "YYYYMMDD"; 15 | export const YMDHM_FORMAT = `${YEAR_MONTH_DAY_FORMAT} ${HOURS_MINUTES_FORMAT}`; 16 | export const YMDHAM_FORMAT = `${YEAR_MONTH_DAY_FORMAT} ${HOURS_AM_FORMAT}`; 17 | -------------------------------------------------------------------------------- /packages/core/src/constants/websocket.constants.ts: -------------------------------------------------------------------------------- 1 | export const EVENT_CHANGE_PROCESSED = "EVENT_CHANGE_PROCESSED"; 2 | export const EVENT_CHANGED = "EVENT_CHANGED"; 3 | export const EVENT_RECEIVED = "EVENT_RECEIVED"; 4 | 5 | export const RESULT_IGNORED = "IGNORED"; 6 | export const RESULT_NOTIFIED_CLIENT = "NOTIFIED_CLIENT"; 7 | -------------------------------------------------------------------------------- /packages/core/src/errors/errors.base.ts: -------------------------------------------------------------------------------- 1 | import { Status } from "./status.codes"; 2 | 3 | interface ErrorMetadata { 4 | description: string; 5 | status: Status; 6 | isOperational: boolean; 7 | } 8 | 9 | export type ErrorConstant = Record; 10 | 11 | export class BaseError extends Error { 12 | public readonly result: string; 13 | public readonly description: string; 14 | // Tech debt: 'statusCode' does not match the 15 | // 'status' key in ErrorMetadata 16 | public readonly statusCode: Status; 17 | public readonly isOperational: boolean; 18 | 19 | constructor( 20 | result: string, 21 | description: string, 22 | statusCode: Status, 23 | isOperational: boolean, 24 | ) { 25 | super(description); 26 | Object.setPrototypeOf(this, new.target.prototype); 27 | 28 | this.result = result; 29 | this.description = description; 30 | this.statusCode = statusCode; 31 | this.isOperational = isOperational; 32 | 33 | Error.captureStackTrace(this); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/core/src/errors/status.codes.ts: -------------------------------------------------------------------------------- 1 | /* https://httpstatuses.com/ */ 2 | export enum Status { 3 | /* 1xx - Information */ 4 | /* 2xx - Success */ 5 | OK = 200, 6 | NO_CONTENT = 204, 7 | 8 | /* 3xx - Redirection */ 9 | /* 4xx - Client Error */ 10 | BAD_REQUEST = 400, 11 | UNAUTHORIZED = 401, 12 | FORBIDDEN = 403, 13 | NOT_FOUND = 404, 14 | GONE = 410, 15 | 16 | /* 5xx - Server Error */ 17 | INTERNAL_SERVER = 500, 18 | NOT_IMPLEMENTED = 501, 19 | 20 | /* 6xx - Custom */ 21 | UNSURE = 600, 22 | REDUX_REFRESH_NEEDED = 601, 23 | } 24 | -------------------------------------------------------------------------------- /packages/core/src/mappers/map.calendarlist.test.ts: -------------------------------------------------------------------------------- 1 | import { gcalCalendarList } from "../__mocks__/v1/calendarlist/gcal.calendarlist"; 2 | import { MapCalendarList } from "./map.calendarlist"; 3 | 4 | it("supports multiple calendars", () => { 5 | const ccallist = MapCalendarList.toCompass(gcalCalendarList); 6 | expect(ccallist.google.items).toHaveLength(1); 7 | expect(ccallist.google.items[0].items.length).toBeGreaterThan(1); 8 | }); 9 | -------------------------------------------------------------------------------- /packages/core/src/mappers/map.calendarlist.ts: -------------------------------------------------------------------------------- 1 | import { gSchema$CalendarList } from "@core/types/gcal"; 2 | 3 | const MapCalendarList = { 4 | toCompass(gcalList: gSchema$CalendarList) { 5 | return { 6 | google: { 7 | items: [gcalList], 8 | }, 9 | }; 10 | }, 11 | }; 12 | 13 | export { MapCalendarList }; 14 | -------------------------------------------------------------------------------- /packages/core/src/mappers/map.user.test.ts: -------------------------------------------------------------------------------- 1 | import { BaseError } from "../errors/errors.base"; 2 | import { mapUserToCompass } from "./map.user"; 3 | 4 | describe("Map to Compass", () => { 5 | it("adds placeholders for acceptible fields", () => { 6 | const gUser = { 7 | iss: "https://accounts.google.com", 8 | azp: "111111520146-mqq17c111hgpgn907j79kgnse1o0lchk.apps.googleusercontent.com", 9 | aud: "111111520146-mqq17c111hgpgn907j79kgnse1o0lchk.apps.googleusercontent.com", 10 | sub: "777777778083505439444", 11 | email: "foobar@gmail.com", 12 | email_verified: true, 13 | at_hash: "YYynQxmPcrF3xGKXgJCB4g", 14 | locale: "en", 15 | iat: 1675219731, 16 | exp: 1675223331, 17 | }; 18 | const cUser = mapUserToCompass(gUser, "refreshToken123"); 19 | expect(cUser.name).toEqual("Mystery Person"); 20 | expect(cUser.firstName).toEqual("Mystery"); 21 | expect(cUser.lastName).toEqual("Person"); 22 | expect(cUser.google.picture).toEqual("not provided"); 23 | }); 24 | it("throws error if missing email", () => { 25 | expect(() => { 26 | mapUserToCompass({}, "refeshToken"); 27 | }).toThrow(BaseError); 28 | }); 29 | it("throws error if missing refresh token", () => { 30 | expect(() => { 31 | mapUserToCompass({ email: "foobar@gmail.com" }, null); 32 | }).toThrow(BaseError); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /packages/core/src/mappers/map.user.ts: -------------------------------------------------------------------------------- 1 | import { BaseError } from "@core/errors/errors.base"; 2 | import { Status } from "@core/errors/status.codes"; 3 | import { UserInfo_Google } from "@core/types/auth.types"; 4 | import { Schema_User } from "@core/types/user.types"; 5 | 6 | // Map user object given by google signin to our schema // 7 | export const mapUserToCompass = ( 8 | gUser: UserInfo_Google["gUser"], 9 | gRefreshToken: string, 10 | ): Schema_User => { 11 | if (!gUser.email || !gRefreshToken) { 12 | throw new BaseError( 13 | `Failed to Map Google User to Compass. \ngUser: ${JSON.stringify({ 14 | ...gUser, 15 | gRefreshToken, 16 | })}`, 17 | "Missing Required GUser Field", 18 | Status.NOT_FOUND, 19 | true, 20 | ); 21 | } 22 | 23 | return { 24 | email: gUser.email, 25 | name: gUser.name || "Mystery Person", 26 | firstName: gUser.given_name || "Mystery", 27 | lastName: gUser.family_name || "Person", 28 | locale: gUser.locale || "not provided", 29 | google: { 30 | googleId: gUser.sub, 31 | picture: gUser.picture || "not provided", 32 | gRefreshToken, 33 | }, 34 | }; 35 | }; 36 | -------------------------------------------------------------------------------- /packages/core/src/mappers/subscriber/map.subscriber.ts: -------------------------------------------------------------------------------- 1 | import { Subscriber } from "@core/types/email/email.types"; 2 | import { Schema_User } from "@core/types/user.types"; 3 | import { Schema_Waitlist } from "@core/types/waitlist/waitlist.types"; 4 | 5 | export const mapCompassUserToEmailSubscriber = ( 6 | user: Schema_User, 7 | ): Subscriber => { 8 | const UNKNOWN = "unknown"; 9 | return { 10 | email_address: user.email, 11 | first_name: user.firstName, 12 | state: "active", 13 | fields: { 14 | "Last name": user.lastName, 15 | Birthday: "1970-01-01", 16 | Source: UNKNOWN, 17 | Role: UNKNOWN, 18 | Company: UNKNOWN, 19 | "Postal code": UNKNOWN, 20 | Website: UNKNOWN, 21 | "Social media": UNKNOWN, 22 | "How did you hear about us?": UNKNOWN, 23 | Interests: UNKNOWN, 24 | Coupon: UNKNOWN, 25 | }, 26 | }; 27 | }; 28 | 29 | export const mapWaitlistUserToEmailSubscriber = ( 30 | user: Schema_Waitlist, 31 | ): Subscriber => { 32 | const subscriber: Subscriber = { 33 | email_address: user.email, 34 | first_name: user.firstName, 35 | state: "active", 36 | fields: { 37 | "Last name": user.lastName, 38 | Birthday: "1970-01-01", 39 | Source: user.source, 40 | }, 41 | }; 42 | return subscriber; 43 | }; 44 | -------------------------------------------------------------------------------- /packages/core/src/types.ts: -------------------------------------------------------------------------------- 1 | export enum KeyCodes { 2 | ESC = 27, 3 | } 4 | -------------------------------------------------------------------------------- /packages/core/src/types/auth.types.ts: -------------------------------------------------------------------------------- 1 | import { Credentials, TokenPayload } from "google-auth-library"; 2 | import { BaseError } from "@core/errors/errors.base"; 3 | 4 | export interface Result_Auth_Compass { 5 | cUserId?: string; 6 | error?: BaseError; 7 | } 8 | 9 | export interface Result_VerifyGToken { 10 | isValid: boolean; 11 | error?: BaseError | Error; 12 | } 13 | 14 | export interface User_Google { 15 | id: string; 16 | email: string; 17 | family_name: string; 18 | given_name: string; 19 | locale: string; 20 | name: string; 21 | picture: string; 22 | verified_email: boolean; 23 | tokens: Credentials; 24 | } 25 | 26 | export interface UserInfo_Google { 27 | gUser: TokenPayload; 28 | tokens: Credentials; 29 | } 30 | -------------------------------------------------------------------------------- /packages/core/src/types/calendar.types.ts: -------------------------------------------------------------------------------- 1 | import { gSchema$CalendarListEntry } from "@core/types/gcal"; 2 | 3 | export interface Schema_CalendarList { 4 | user: string; 5 | google: { 6 | items: gSchema$CalendarListEntry[]; 7 | }; 8 | } 9 | -------------------------------------------------------------------------------- /packages/core/src/types/email/email.types.ts: -------------------------------------------------------------------------------- 1 | import z from "zod"; 2 | 3 | export const SubscriberStateSchema = z 4 | .enum(["active", "bounced", "cancelled", "complained", "inactive"]) 5 | .nullable() 6 | .optional() 7 | .default("active"); 8 | export type SubscriberState = z.infer; 9 | 10 | export const SubscriberSchema = z.object({ 11 | email_address: z.string().email(), 12 | first_name: z.string().nullable().optional(), 13 | state: SubscriberStateSchema, 14 | fields: z.object({ 15 | "Last name": z.string(), 16 | Birthday: z.string(), 17 | Source: z.string(), 18 | Role: z.string().optional(), 19 | Company: z.string().optional(), 20 | "Postal code": z.string().optional(), 21 | Website: z.string().optional(), 22 | "Social media": z.string().optional(), 23 | "How did you hear about us?": z.string().optional(), 24 | Interests: z.string().optional(), 25 | Coupon: z.string().optional(), 26 | }), 27 | }); 28 | 29 | export type Subscriber = z.infer; 30 | -------------------------------------------------------------------------------- /packages/core/src/types/jwt.types.ts: -------------------------------------------------------------------------------- 1 | export type JwtToken = { _id: string; exp: number; iat: number }; 2 | export interface Result_Token_Validate { 3 | payload?: JwtToken; 4 | refreshNeeded: boolean; 5 | error?: unknown; 6 | } 7 | -------------------------------------------------------------------------------- /packages/core/src/types/mongo.types.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from "mongodb"; 2 | 3 | export type InsertedIds = { [key: number]: ObjectId }; 4 | -------------------------------------------------------------------------------- /packages/core/src/types/priority.types.ts: -------------------------------------------------------------------------------- 1 | export interface PriorityReq { 2 | name: string; 3 | } 4 | 5 | export interface PriorityReqUser extends PriorityReq { 6 | user: string; 7 | } 8 | export interface Schema_Priority extends PriorityReq { 9 | _id: string; 10 | user: string; 11 | } 12 | 13 | export enum Priorities { 14 | WORK = "work", 15 | SELF = "self", 16 | RELATIONS = "relations", 17 | } 18 | -------------------------------------------------------------------------------- /packages/core/src/types/type.utils.ts: -------------------------------------------------------------------------------- 1 | export type KeyOfType = keyof { 2 | [P in keyof T as T[P] extends V ? P : never]: any; 3 | }; 4 | 5 | //example: 6 | //data: Omit 7 | // same as Payload..., except for "nextSyncToken" 8 | export type Omit = Pick>; 9 | -------------------------------------------------------------------------------- /packages/core/src/types/user.types.ts: -------------------------------------------------------------------------------- 1 | export interface Schema_User { 2 | email: string; 3 | firstName: string; 4 | lastName: string; 5 | name: string; 6 | locale: string; 7 | google: { 8 | googleId: string; 9 | picture: string; 10 | gRefreshToken: string; 11 | }; 12 | signedUpAt?: Date; 13 | lastLoggedInAt?: Date; 14 | } 15 | -------------------------------------------------------------------------------- /packages/core/src/types/waitlist/waitlist.answer.types.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | /* v0 */ 4 | export const Schema_Answers_v0 = z.object({ 5 | email: z.string().email(), 6 | schemaVersion: z.literal("0"), 7 | source: z.enum(["search-engine", "social-media", "friend", "other"]), 8 | firstName: z.string().min(2), 9 | lastName: z.string().min(2), 10 | currentlyPayingFor: z.array(z.string()).optional(), 11 | howClearAboutValues: z.enum(["not-clear", "somewhat-clear", "very-clear"]), 12 | workingTowardsMainGoal: z.enum([ 13 | "yes", 14 | "no-but-want-to", 15 | "no-and-dont-want-to", 16 | ]), 17 | isWillingToShare: z.boolean(), 18 | anythingElse: z.string().optional(), 19 | }); 20 | export type Answers_v0 = z.infer; 21 | 22 | export type Answers = Answers_v0; // make a union type when adding more versions 23 | 24 | export const AnswerMap = { 25 | v0: Schema_Answers_v0, 26 | //v1: Schema_Answers_v1 <-- Extend/change for new version to avoid migration 27 | }; 28 | -------------------------------------------------------------------------------- /packages/core/src/types/waitlist/waitlist.types.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { Schema_Answers_v0 } from "./waitlist.answer.types"; 3 | 4 | export interface Result_Waitlist { 5 | status: "waitlisted" | "ignored"; 6 | } 7 | 8 | export interface Result_InviteToWaitlist { 9 | status: "invited" | "ignored"; 10 | } 11 | 12 | const Schema_Status = z.enum(["waitlisted", "invited", "active"]); 13 | 14 | const Schema_Waitlist_v0 = Schema_Answers_v0.extend({ 15 | status: Schema_Status, 16 | waitlistedAt: z.string().datetime(), 17 | }); 18 | export type Schema_Waitlist_v0 = z.infer; 19 | export type Schema_Waitlist = Schema_Waitlist_v0; // make a union type when adding more versions 20 | -------------------------------------------------------------------------------- /packages/core/src/types/websocket.types.ts: -------------------------------------------------------------------------------- 1 | import { Server as SocketIOServer } from "socket.io"; 2 | import { Schema_Event } from "./event.types"; 3 | 4 | export interface ClientToServerEvents { 5 | EVENT_CHANGE_PROCESSED: (clientId: string) => void; 6 | } 7 | 8 | export type CompassSocketServer = SocketIOServer< 9 | ClientToServerEvents, 10 | ServerToClientEvents, 11 | InterServerEvents, 12 | SocketData 13 | >; 14 | export interface InterServerEvents { 15 | EVENT_RECEIVED: (data: Schema_Event) => Schema_Event; 16 | } 17 | 18 | export interface ServerToClientEvents { 19 | EVENT_CHANGED: () => void; 20 | } 21 | 22 | export interface SocketData { 23 | userId: string; 24 | } 25 | -------------------------------------------------------------------------------- /packages/core/src/util/app.util.ts: -------------------------------------------------------------------------------- 1 | import { NodeEnv } from "@core/constants/core.constants"; 2 | import { isDev } from "@core/util/env.util"; 3 | 4 | export const devAlert = (message: string) => { 5 | if (isDev(process.env["NODE_ENV"] as NodeEnv)) { 6 | alert(message); 7 | } else { 8 | console.warn(message); 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /packages/core/src/util/color.utils.ts: -------------------------------------------------------------------------------- 1 | import tinycolor from "tinycolor2"; 2 | 3 | export const brighten = (color: string, amount?: number) => { 4 | return tinycolor(color).brighten(amount).toString(); 5 | }; 6 | 7 | export const darken = (color: string, amount?: number) => { 8 | return tinycolor(color).darken(amount).toString(); 9 | }; 10 | 11 | export const isDark = (color: string) => { 12 | return tinycolor(color).isDark(); 13 | }; 14 | -------------------------------------------------------------------------------- /packages/core/src/util/env.util.ts: -------------------------------------------------------------------------------- 1 | import { NodeEnv } from "@core/constants/core.constants"; 2 | 3 | export const isDev = (nodeEnv: NodeEnv | string) => 4 | nodeEnv === NodeEnv.Development; 5 | -------------------------------------------------------------------------------- /packages/core/src/util/event/event.util.test.ts: -------------------------------------------------------------------------------- 1 | import { categorizeEvents } from "@core/util/event/event.util"; 2 | import { 3 | createMockBaseEvent, 4 | createMockInstance, 5 | createMockStandaloneEvent, 6 | } from "@core/util/test/ccal.event.factory"; 7 | 8 | describe("categorizeEvents", () => { 9 | it("should categorize events correctly", () => { 10 | const standalone = createMockStandaloneEvent(); 11 | const base = createMockBaseEvent(); 12 | const instance = createMockInstance(base._id, base.gEventId as string); 13 | const events = [base, instance, standalone]; 14 | 15 | const { baseEvents, instances, standaloneEvents } = 16 | categorizeEvents(events); 17 | 18 | expect(baseEvents).toEqual([base]); 19 | expect(instances).toEqual([instance]); 20 | expect(standaloneEvents).toEqual([standalone]); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /packages/core/src/validators/event.validator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CoreEventSchema, 3 | Event_Core, 4 | Schema_Event, 5 | } from "@core/types/event.types"; 6 | 7 | export const validateEvent = (event: Schema_Event): Event_Core => { 8 | const result = CoreEventSchema.parse(event); 9 | return result; 10 | }; 11 | -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./build", 5 | "sourceMap": false, 6 | "inlineSourceMap": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/scripts/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@compass/scripts", 3 | "description": "Compass Scripts", 4 | "license": "MIT", 5 | "version": "1.0.0", 6 | "main": "./src/cli.ts", 7 | "private": true, 8 | "dependencies": { 9 | "@compass/core": "1.0.0", 10 | "commander": "^10.0.0", 11 | "dotenv": "^16.0.1", 12 | "inquirer": "^8.0.0", 13 | "shelljs": "^0.8.5" 14 | }, 15 | "devDependencies": { 16 | "@types/inquirer": "^9.0.1", 17 | "@types/node": "^20.6.3", 18 | "@types/shelljs": "^0.8.15", 19 | "ts-node-dev": "^2.0.0", 20 | "tsconfig-paths": "^4.0.0", 21 | "typescript": "^5.1.6" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/scripts/src/commands/devWeb.js: -------------------------------------------------------------------------------- 1 | const shell = require("shelljs"); 2 | const dotenv = require("dotenv"); 3 | const path = require("path"); 4 | 5 | const envPath = path.join(__dirname, "..", "..", "..", "backend", ".env"); 6 | dotenv.config({ path: envPath }); 7 | 8 | const clientId = process.env.CLIENT_ID; 9 | const port = process.env.PORT; 10 | const baseUrl = process.env.BASEURL || `http://localhost:${port}/api`; 11 | 12 | const devWeb = () => { 13 | shell.exec( 14 | `cd packages/web && yarn webpack serve --mode=development --env API_BASEURL=${baseUrl} API_PORT=${port} IS_DEV=true GOOGLE_CLIENT_ID=${clientId}`, 15 | ); 16 | }; 17 | 18 | devWeb(); 19 | -------------------------------------------------------------------------------- /packages/scripts/src/commands/invite.ts: -------------------------------------------------------------------------------- 1 | import { _confirm, log } from "@scripts/common/cli.utils"; 2 | import mongoService from "@backend/common/services/mongo.service"; 3 | import WaitlistService from "@backend/waitlist/service/waitlist.service"; 4 | 5 | mongoService; 6 | 7 | export const inviteWaitlist = async () => { 8 | await mongoService.waitUntilConnected(); 9 | 10 | const waitlisted = await WaitlistService.getAllWaitlisted(); 11 | log.success(`Total on waitlist: ${waitlisted.length}`); 12 | 13 | if (waitlisted.length === 0) { 14 | log.info("No users on waitlist"); 15 | process.exit(0); 16 | } 17 | 18 | for (const record of waitlisted) { 19 | console.log(record); 20 | const shouldInvite = await _confirm("Invite this user?"); 21 | if (shouldInvite) { 22 | console.log("Adding to waitlist..."); 23 | await WaitlistService.invite(record.email); 24 | } 25 | } 26 | process.exit(0); 27 | }; 28 | -------------------------------------------------------------------------------- /packages/scripts/src/common/cli.constants.ts: -------------------------------------------------------------------------------- 1 | export const ALL_PACKAGES = ["nodePckgs", "web"]; 2 | 3 | export const COMPASS_ROOT_DEV = process.cwd(); 4 | export const COMPASS_BUILD_DEV = `${COMPASS_ROOT_DEV}/build`; 5 | export const NODE_BUILD = `${COMPASS_BUILD_DEV}/node`; 6 | 7 | export const PCKG = { 8 | NODE: "nodePckgs", 9 | WEB: "web", 10 | }; 11 | 12 | export const CATEGORY_VM = { 13 | STAG: "staging", 14 | PROD: "production", 15 | }; 16 | 17 | export const CLI_ENV = { 18 | LOCAL_DOMAIN: process.env["LOCAL_DOMAIN"] || `localhost:3000`, 19 | STAGING_DOMAIN: process.env["STAGING_DOMAIN"], 20 | PROD_DOMAIN: process.env["PROD_DOMAIN"], 21 | }; 22 | -------------------------------------------------------------------------------- /packages/scripts/src/common/cli.types.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const Schema_Options_Cli_Root = z.object({ 4 | force: z.boolean().optional(), 5 | }); 6 | 7 | export const Schema_Options_Cli_Build = z.object({ 8 | clientId: z.string().optional(), 9 | environment: z.enum(["staging", "production"]).optional(), 10 | packages: z.array(z.string()).optional(), 11 | }); 12 | 13 | export const Schema_Options_Cli_Delete = z.object({ 14 | user: z.string().optional(), 15 | }); 16 | 17 | export type Options_Cli_Delete = z.infer; 18 | export type Options_Cli_Build = z.infer; 19 | export type Options_Cli_Root = z.infer; 20 | export type Options_Cli = Options_Cli_Root & 21 | Options_Cli_Build & 22 | Options_Cli_Delete; 23 | 24 | export type Environment_Cli = "local" | "staging" | "production"; 25 | -------------------------------------------------------------------------------- /packages/scripts/src/init.ts: -------------------------------------------------------------------------------- 1 | import dotenv from "dotenv"; 2 | import path from "path"; 3 | 4 | dotenv.config({ 5 | path: path.resolve(process.cwd(), "packages/backend/.env"), 6 | }); 7 | -------------------------------------------------------------------------------- /packages/scripts/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "ts-node": { 4 | "files": true, 5 | // Skip typechecking for faster development 6 | "transpileOnly": true, 7 | "compilerOptions": { 8 | // compilerOptions specified here will override those declared below, 9 | // but *only* in ts-node. Useful if you want ts-node and tsc to use 10 | // different options with a single tsconfig.json. 11 | } 12 | }, 13 | "compilerOptions": { 14 | "sourceMap": false 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/web/declaration.d.ts: -------------------------------------------------------------------------------- 1 | interface ClassNames { 2 | [className: string]: string; 3 | } 4 | declare const classNames: ClassNames; 5 | declare module "*.scss" { 6 | export = classNames; 7 | } 8 | 9 | declare const imageUrl: string; 10 | declare module "*.png" { 11 | export = imageUrl; 12 | } 13 | 14 | declare module "*.svg" { 15 | import * as React from "react"; 16 | 17 | const ReactComponent: React.FunctionComponent>; 18 | 19 | export default ReactComponent; 20 | } 21 | 22 | declare module "*.jpg" { 23 | export = imageUrl; 24 | } 25 | 26 | declare module "*.jpeg" { 27 | export = imageUrl; 28 | } 29 | -------------------------------------------------------------------------------- /packages/web/src/__tests__/__mocks__/css.stub.js: -------------------------------------------------------------------------------- 1 | // empty object to assist jest in processing 2 | // css files 3 | // eslint-disable-next-line no-undef 4 | module.export = {}; 5 | -------------------------------------------------------------------------------- /packages/web/src/__tests__/__mocks__/file.stub.js: -------------------------------------------------------------------------------- 1 | module.exports = "test-file-stub"; 2 | -------------------------------------------------------------------------------- /packages/web/src/__tests__/__mocks__/server/mock.server.ts: -------------------------------------------------------------------------------- 1 | import { setupServer } from "msw/node"; 2 | import { globalHandlers } from "./mock.handlers"; 3 | 4 | // This configures a request mocking server with the given request handlers. 5 | export const server = setupServer(...globalHandlers); 6 | -------------------------------------------------------------------------------- /packages/web/src/__tests__/__mocks__/svg.stub.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | /* 4 | Supports SVGr mocking for Jest / React Testing Library 5 | Reference: https://github.com/gregberge/svgr/issues/83 6 | */ 7 | 8 | const SvgrMock = React.forwardRef((props, ref) => ( 9 | 10 | )); 11 | 12 | export const ReactComponent = SvgrMock; 13 | export default SvgrMock; 14 | -------------------------------------------------------------------------------- /packages/web/src/__tests__/__mocks__/tabbable.js: -------------------------------------------------------------------------------- 1 | const lib = jest.requireActual("tabbable"); 2 | 3 | /* 4 | Mocks the 'tabbable' library, used by focus-trap 5 | 6 | https://github.com/focus-trap/tabbable 7 | 8 | https://stackoverflow.com/questions/72762696/jest-error-your-focus-trap-must-have-at-least-one-container-with-at-least-one 9 | */ 10 | const tabbable = { 11 | ...lib, 12 | tabbable: (node, options) => 13 | lib.tabbable(node, { 14 | ...options, 15 | displayCheck: "none", 16 | }), 17 | focusable: (node, options) => 18 | lib.focusable(node, { 19 | ...options, 20 | displayCheck: "none", 21 | }), 22 | isFocusable: (node, options) => 23 | lib.isFocusable(node, { 24 | ...options, 25 | displayCheck: "none", 26 | }), 27 | isTabbable: (node, options) => 28 | lib.isTabbable(node, { 29 | ...options, 30 | displayCheck: "none", 31 | }), 32 | }; 33 | 34 | module.exports = tabbable; 35 | -------------------------------------------------------------------------------- /packages/web/src/__tests__/utils/date.util/date.label.test.ts: -------------------------------------------------------------------------------- 1 | import { getTimesLabel } from "@web/common/utils/web.date.util"; 2 | 3 | const meridians = (label: string) => 4 | (label.match(/am/gi) || label.match(/pm/gi) || []).length; 5 | 6 | describe("Time Labels", () => { 7 | it("removes minutes and am/pm when possible", () => { 8 | const morningLabel = getTimesLabel( 9 | "2022-07-06T06:00:00-05:00", 10 | "2022-07-06T07:00:00-05:00", 11 | ); 12 | expect(meridians(morningLabel)).toBe(1); 13 | 14 | const eveningLabel = getTimesLabel( 15 | "2022-07-06T20:00:00-05:00", 16 | "2022-07-06T23:00:00-05:00", 17 | ); 18 | expect(meridians(eveningLabel)).toBe(1); 19 | }); 20 | 21 | it("preserves am/pm when needed", () => { 22 | const label = getTimesLabel( 23 | "2022-07-06T01:00:00-05:00", 24 | "2022-07-06T18:00:00-05:00", 25 | ); 26 | expect(label.includes("AM")).toBe(true); 27 | expect(label.includes("PM")).toBe(true); 28 | }); 29 | it("preserves minutes when needed", () => { 30 | const label = getTimesLabel( 31 | "2022-07-06T09:45:00-05:00", 32 | "2022-07-06T19:15:00-05:00", 33 | ); 34 | expect(label.includes(":45")).toBe(true); 35 | expect(label.includes(":15")).toBe(true); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /packages/web/src/__tests__/utils/date.util/date.parse.test.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { Categories_Event } from "@core/types/event.types"; 3 | import { getDatesByCategory } from "@web/common/utils/web.date.util"; 4 | 5 | describe("Date Categories: Month", () => { 6 | it("uses first and last of month: case1", () => { 7 | const dates = getDatesByCategory( 8 | Categories_Event.SOMEDAY_MONTH, 9 | dayjs("2023-06-04"), 10 | dayjs("2023-06-10"), 11 | ); 12 | 13 | expect(dates).toEqual({ 14 | startDate: "2023-06-01", 15 | endDate: "2023-06-30", 16 | }); 17 | }); 18 | 19 | it("returns first & last of month based on start date", () => { 20 | const dates = getDatesByCategory( 21 | Categories_Event.SOMEDAY_MONTH, 22 | dayjs("2023-06-25"), 23 | dayjs("2023-07-01"), 24 | ); 25 | 26 | expect(dates).toEqual({ 27 | startDate: "2023-06-01", 28 | endDate: "2023-06-30", 29 | }); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /packages/web/src/__tests__/utils/test.util.ts: -------------------------------------------------------------------------------- 1 | interface Spies { 2 | [key: string]: jest.SpyInstance; 3 | } 4 | const spies: Spies = {}; 5 | 6 | export const arraysAreEqual = (a: any[], b: any[]) => { 7 | return ( 8 | Array.isArray(a) && 9 | Array.isArray(b) && 10 | a.length === b.length && 11 | a.every((val, index) => val === b[index]) 12 | ); 13 | }; 14 | 15 | export const clearLocalStorageMock = () => { 16 | Object.keys(spies).forEach((key: string) => spies[key].mockRestore()); 17 | }; 18 | 19 | export const mockLocalStorage = () => { 20 | ["setItem", "getItem", "removeItem", "clear"].forEach((fn: string) => { 21 | const mock = jest.fn(localStorage[fn]); 22 | spies[fn] = jest.spyOn(Storage.prototype, fn).mockImplementation(mock); 23 | }); 24 | }; 25 | 26 | export const mockResizeObserver = () => { 27 | global.ResizeObserver = jest.fn().mockImplementation(() => ({ 28 | observe: jest.fn(), 29 | unobserve: jest.fn(), 30 | disconnect: jest.fn(), 31 | })); 32 | }; 33 | 34 | export const mockScroll = () => { 35 | window.HTMLElement.prototype.scroll = jest.fn(); 36 | }; 37 | -------------------------------------------------------------------------------- /packages/web/src/__tests__/web.test.init.js: -------------------------------------------------------------------------------- 1 | process.env.API_BASEURL = "http://localhost:3000/api"; 2 | process.env.GOOGLE_CLIENT_ID = "mockedClientId"; 3 | -------------------------------------------------------------------------------- /packages/web/src/__tests__/web.test.start.js: -------------------------------------------------------------------------------- 1 | import { server } from "@web/__tests__/__mocks__/server/mock.server"; 2 | import { 3 | clearLocalStorageMock, 4 | mockResizeObserver, 5 | mockScroll, 6 | } from "@web/__tests__/utils/test.util"; 7 | 8 | beforeAll(() => { 9 | mockScroll(); 10 | mockResizeObserver(); 11 | 12 | server.listen(); 13 | }); 14 | 15 | // Reset any request handlers that we may add during the tests, 16 | // so they don't affect other tests. 17 | afterEach(() => server.resetHandlers()); 18 | 19 | // Clean up after the tests are finished. 20 | afterAll(() => { 21 | server.close(); 22 | 23 | clearLocalStorageMock(); 24 | }); 25 | -------------------------------------------------------------------------------- /packages/web/src/assets/png/derek.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SwitchbackTech/compass/b4d777dbb5bd83406b8d0ae2be5fd358917a09ac/packages/web/src/assets/png/derek.png -------------------------------------------------------------------------------- /packages/web/src/assets/png/notFound.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SwitchbackTech/compass/b4d777dbb5bd83406b8d0ae2be5fd358917a09ac/packages/web/src/assets/png/notFound.png -------------------------------------------------------------------------------- /packages/web/src/auth/ProtectedRoute.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode, useEffect } from "react"; 2 | import { useNavigate } from "react-router-dom"; 3 | import { AUTH_FAILURE_REASONS } from "@web/common/constants/auth.constants"; 4 | import { ROOT_ROUTES } from "@web/common/constants/routes"; 5 | import { AbsoluteOverflowLoader } from "@web/components/AbsoluteOverflowLoader"; 6 | import { useAuthCheck } from "./useAuthCheck"; 7 | 8 | export const ProtectedRoute = ({ children }: { children: ReactNode }) => { 9 | const navigate = useNavigate(); 10 | 11 | const { isAuthenticated, isCheckingAuth, isGoogleTokenActive } = 12 | useAuthCheck(); 13 | 14 | useEffect(() => { 15 | const handleAuthCheck = () => { 16 | if (isAuthenticated === false) { 17 | if (isGoogleTokenActive === false) { 18 | navigate( 19 | `${ROOT_ROUTES.LOGIN}?reason=${AUTH_FAILURE_REASONS.GAUTH_SESSION_EXPIRED}`, 20 | ); 21 | } else { 22 | navigate( 23 | `${ROOT_ROUTES.LOGIN}?reason=${AUTH_FAILURE_REASONS.USER_SESSION_EXPIRED}`, 24 | ); 25 | } 26 | } 27 | }; 28 | 29 | void handleAuthCheck(); 30 | }, [isAuthenticated, isGoogleTokenActive, navigate]); 31 | 32 | return ( 33 | <> 34 | {isCheckingAuth && } 35 | {children} 36 | 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /packages/web/src/auth/auth.util.ts: -------------------------------------------------------------------------------- 1 | import Session from "supertokens-auth-react/recipe/session"; 2 | 3 | interface AccessTokenPayload { 4 | sub: string; 5 | } 6 | 7 | export const getUserId = async () => { 8 | const accessTokenPayload = 9 | (await Session.getAccessTokenPayloadSecurely()) as AccessTokenPayload; 10 | const userId = accessTokenPayload["sub"]; 11 | return userId; 12 | }; 13 | -------------------------------------------------------------------------------- /packages/web/src/auth/useAuthCheck.ts: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect, useState } from "react"; 2 | import Session from "supertokens-auth-react/recipe/session"; 3 | import { AuthApi } from "@web/common/apis/auth.api"; 4 | 5 | export const useAuthCheck = () => { 6 | const [isCheckingAuth, setIsCheckingAuth] = useState(false); 7 | const [isAuthenticated, setIsAuthenticated] = useState(null); 8 | const [isSessionActive, setIsSessionActive] = useState(null); 9 | const [isGoogleTokenActive, setIsGoogleTokenActive] = useState< 10 | boolean | null 11 | >(null); 12 | 13 | useLayoutEffect(() => { 14 | const checkAuth = async () => { 15 | try { 16 | setIsCheckingAuth(true); 17 | const _isSessionActive = await Session.doesSessionExist(); 18 | setIsSessionActive(_isSessionActive); 19 | 20 | const _isGoogleTokenActive = await AuthApi.validateGoogleAccessToken(); 21 | setIsGoogleTokenActive(_isGoogleTokenActive); 22 | 23 | setIsAuthenticated(isSessionActive && isGoogleTokenActive); 24 | } catch (error) { 25 | console.error("Error checking authentication:", error); 26 | } finally { 27 | setIsCheckingAuth(false); 28 | } 29 | }; 30 | 31 | void checkAuth(); 32 | }, [isGoogleTokenActive, isSessionActive]); 33 | 34 | return { 35 | isAuthenticated, 36 | isCheckingAuth, 37 | isGoogleTokenActive, 38 | isSessionActive, 39 | }; 40 | }; 41 | -------------------------------------------------------------------------------- /packages/web/src/common/apis/auth.api.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Result_Auth_Compass, 3 | Result_VerifyGToken, 4 | } from "@core/types/auth.types"; 5 | import { CompassApi } from "./compass.api"; 6 | 7 | const AuthApi = { 8 | async loginOrSignup(code: string) { 9 | const response = await CompassApi.post(`/oauth/google`, { code }); 10 | return response.data as Result_Auth_Compass; 11 | }, 12 | async validateGoogleAccessToken() { 13 | try { 14 | const res = await CompassApi.get(`/auth/google`); 15 | 16 | if (res.status !== 200) return false; 17 | 18 | const body = res.data as Result_VerifyGToken; 19 | return !!body.isValid; 20 | } catch (error) { 21 | console.error(error); 22 | return false; 23 | } 24 | }, 25 | }; 26 | 27 | export { AuthApi }; 28 | -------------------------------------------------------------------------------- /packages/web/src/common/apis/calendarlist.api.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { Schema_CalendarList } from "@core/types/calendar.types"; 3 | import { gSchema$CalendarList } from "@core/types/gcal"; 4 | import { ENV_WEB } from "@web/common/constants/env.constants"; 5 | import { headers } from "@web/common/utils"; 6 | 7 | const CalendarListApi = { 8 | async list(): Promise { 9 | const response: Schema_CalendarList = await axios.get( 10 | `${ENV_WEB.API_BASEURL}/calendarlist`, 11 | headers(), 12 | ); 13 | return response.data; 14 | }, 15 | 16 | async create(payload: Schema_CalendarList) { 17 | const response = await axios.post( 18 | `${ENV_WEB.API_BASEURL}/calendarlist`, 19 | payload, 20 | headers(), 21 | ); 22 | return response.data; 23 | }, 24 | }; 25 | 26 | export { CalendarListApi }; 27 | -------------------------------------------------------------------------------- /packages/web/src/common/apis/priority.api.ts: -------------------------------------------------------------------------------- 1 | /* demo for future reference /extension 2 | 3 | import axios from "axios"; 4 | import { Priorities } from "@core/constants/core.constants"; 5 | import { colorNameByPriority } from "@core/constants/colors"; 6 | import { API_BASEURL } from "@web/common/constants/web.constants"; 7 | 8 | import { headers } from "../utils"; 9 | 10 | const PriorityApi = { 11 | async createPriority(token: string) { 12 | const priority = { 13 | name: "custom name", 14 | color: "user-selected color", 15 | }; 16 | 17 | const _headers = headers(token); 18 | 19 | const [p1] = await Promise.all([ 20 | await axios.post(`${API_BASEURL}/priority`, priority, _headers), 21 | ]); 22 | 23 | const combined = [p1]; 24 | return combined; 25 | }, 26 | 27 | async getPriorities() { 28 | const response = await axios.get(`${API_BASEURL}/priority`, headers()); 29 | return response.data; 30 | }, 31 | }; 32 | 33 | export { PriorityApi }; 34 | */ 35 | -------------------------------------------------------------------------------- /packages/web/src/common/apis/sync.api.ts: -------------------------------------------------------------------------------- 1 | import { CompassApi } from "./compass.api"; 2 | 3 | const SyncApi = { 4 | async stopWatches() { 5 | const response = await CompassApi.post(`/sync/stop-all`); 6 | return response; 7 | }, 8 | }; 9 | 10 | export { SyncApi }; 11 | -------------------------------------------------------------------------------- /packages/web/src/common/apis/waitlist.api.ts: -------------------------------------------------------------------------------- 1 | import { CompassApi } from "./compass.api"; 2 | 3 | export const WaitlistApi = { 4 | async getWaitlistStatus(email: string) { 5 | const { data } = await CompassApi.get<{ 6 | isOnWaitlist: boolean; 7 | isInvited: boolean; 8 | isActive: boolean; 9 | }>(`/waitlist`, { params: { email } }); 10 | return data; 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /packages/web/src/common/constants/auth.constants.ts: -------------------------------------------------------------------------------- 1 | export const AUTH_FAILURE_REASONS = { 2 | // User's google auth session is invalid or expired 3 | GAUTH_SESSION_EXPIRED: "gauth-session-expired", 4 | USER_SESSION_EXPIRED: "user-session-expired", 5 | }; 6 | -------------------------------------------------------------------------------- /packages/web/src/common/constants/env.constants.ts: -------------------------------------------------------------------------------- 1 | import { isDev } from "@core/util/env.util"; 2 | 3 | export const IS_DEV = isDev(process.env["NODE_ENV"]); 4 | 5 | const API_BASEURL = 6 | process.env["API_BASEURL"] || `http://localhost:${process.env["PORT"]}`; 7 | const BACKEND_BASEURL = API_BASEURL.replace(/\/[^/]*$/, ""); 8 | 9 | export const ENV_WEB = { 10 | API_BASEURL, 11 | BACKEND_BASEURL, 12 | CLIENT_ID: process.env["GOOGLE_CLIENT_ID"], 13 | }; 14 | -------------------------------------------------------------------------------- /packages/web/src/common/constants/routes.ts: -------------------------------------------------------------------------------- 1 | export const ROOT_ROUTES = { 2 | API: "/api", 3 | LOGIN: "/login", 4 | LOGOUT: "/logout", 5 | ROOT: "/", 6 | }; 7 | -------------------------------------------------------------------------------- /packages/web/src/common/constants/storage.constants.ts: -------------------------------------------------------------------------------- 1 | export const STORAGE_KEYS = { 2 | HEADER_NOTE: "compass.headerNote", 3 | }; 4 | -------------------------------------------------------------------------------- /packages/web/src/common/store/middlewares/index.ts: -------------------------------------------------------------------------------- 1 | import createSagaMiddleware from "redux-saga"; 2 | import "regenerator-runtime/runtime.js"; 3 | 4 | export const sagaMiddleware = createSagaMiddleware(); 5 | -------------------------------------------------------------------------------- /packages/web/src/common/styles/colors.ts: -------------------------------------------------------------------------------- 1 | export const c = { 2 | black: "hsla(0 0 0 / 25%)", 3 | blue300: "hsl(195 78 56)", 4 | blue200: "hsl(196 60 59)", 5 | blue100: "hsl(202 100 67)", 6 | blueGray400: "hsl(207 14 57)", 7 | blueGray300: "hsl(205 33 61)", 8 | blueGray200: "hsl(205 36 62)", 9 | blueGray100: "hsl(196 45 78)", 10 | darkBlue500: "hsl(220 29 6)", 11 | darkBlue400: "hsl(222 28 7)", 12 | darkBlue300: "hsl(218 27 8)", 13 | darkBlue200: "hsl(218 24 9)", 14 | darkBlue100: "hsl(223 27 10)", 15 | gray900: "hsl(0 0 0 / 50.2%)", 16 | gray800: "hsl(219 18 34 / 20%)", 17 | gray700: "hsl(219 18 34 25.1%)", 18 | gray600: "hsl(219 8 46 / 20%)", 19 | gray500: "hsl(219 8 46 / 20%)", 20 | gray400: "hsl(221 9 37)", 21 | gray300: "hsl(219 8 46 / 90.2%)", 22 | gray200: "hsl(208 13 71 / 54.9%)", 23 | gray100: "hsl(47 7 73)", 24 | green: "hsl(105 61 62)", 25 | orange: "hsl(25 100 63)", 26 | purple: "hsl(270 100 83)", 27 | red: "hsl(0 63 60)", 28 | teal: "hsl(163 44 67)", 29 | white200: "hsl(0 0 98)", 30 | white100: "hsl(0 0 100)", 31 | }; 32 | 33 | export const textLight = c.gray100; 34 | export const textDark = c.darkBlue400; 35 | -------------------------------------------------------------------------------- /packages/web/src/common/types/api.types.ts: -------------------------------------------------------------------------------- 1 | export interface ApiError { 2 | code: string; 3 | } 4 | 5 | export interface Options_Sort { 6 | sortBy: string; 7 | order: "asc" | "desc"; 8 | } 9 | 10 | export interface Filters_Pagination { 11 | offset?: number; 12 | page?: number; 13 | pageSize: number; 14 | } 15 | 16 | export type Options_FilterSort = Filters_Pagination & Options_Sort; 17 | 18 | export interface Response_HttpPaginatedSuccess 19 | extends Filters_Pagination { 20 | data: Data; 21 | count: number; 22 | [key: string]: unknown | undefined; 23 | } 24 | -------------------------------------------------------------------------------- /packages/web/src/common/types/component.types.ts: -------------------------------------------------------------------------------- 1 | export interface ClassNamedComponent { 2 | className?: string; 3 | } 4 | 5 | export interface UnderlinedInput { 6 | underlineColor?: string; 7 | withUnderline?: boolean; 8 | } 9 | 10 | export interface SelectOption { 11 | value: T; 12 | label: string; 13 | } 14 | -------------------------------------------------------------------------------- /packages/web/src/common/types/dnd.types.ts: -------------------------------------------------------------------------------- 1 | import { Schema_Event } from "@core/types/event.types"; 2 | 3 | export enum Category_DragItem { 4 | EVENT_SOMEDAY = "event_someday", 5 | EVENT_DATE = "event_date", 6 | EVENT_DATETIME = "event_datetime", 7 | } 8 | 9 | export interface DragItem_Someday { 10 | _id: Schema_Event["_id"]; 11 | description: Schema_Event["description"]; 12 | order: Schema_Event["order"]; 13 | priority: Schema_Event["priority"]; 14 | title: Schema_Event["title"]; 15 | } 16 | 17 | export interface DropResult_ReactDND { 18 | _id: Schema_Event["_id"]; 19 | description: Schema_Event["description"]; 20 | priority: Schema_Event["priority"]; 21 | title: Schema_Event["title"]; 22 | } 23 | -------------------------------------------------------------------------------- /packages/web/src/common/types/entity.types.ts: -------------------------------------------------------------------------------- 1 | export type Payload_NormalizedAsyncAction = T[]; 2 | -------------------------------------------------------------------------------- /packages/web/src/common/types/util.types.ts: -------------------------------------------------------------------------------- 1 | import { Dayjs } from "dayjs"; 2 | 3 | export interface AssignResult { 4 | fits: boolean; 5 | rowNum?: number; 6 | } 7 | export interface Coordinates { 8 | x: number; 9 | y: number; 10 | } 11 | export interface Option_Time { 12 | label: string; 13 | value: string; 14 | } 15 | export interface Params_DateChange { 16 | start: Date; 17 | end: Date; 18 | } 19 | export interface Params_TimeChange { 20 | oldStart: string; 21 | oldEnd: string; 22 | start: string; 23 | end: string; 24 | } 25 | 26 | export interface Range_Week { 27 | weekStart: Dayjs; 28 | weekEnd: Dayjs; 29 | } 30 | 31 | export interface WidthPercentages { 32 | current: number[]; 33 | pastFuture: number[]; 34 | } 35 | 36 | export interface WidthPixels { 37 | current: { 38 | sidebarOpen: number[]; 39 | sidebarClosed: number[]; 40 | }; 41 | pastFuture: { 42 | sidebarOpen: number; 43 | sidebarClosed: number; 44 | }; 45 | } 46 | 47 | export type Ref_Callback = (node: HTMLDivElement) => void; 48 | 49 | export type PartialMouseEvent = Pick< 50 | MouseEvent, 51 | "clientX" | "clientY" | "currentTarget" 52 | >; 53 | -------------------------------------------------------------------------------- /packages/web/src/common/utils/device.util.ts: -------------------------------------------------------------------------------- 1 | export enum DesktopOS { 2 | Linux = "linux", 3 | MacOS = "mac_os", 4 | Windows = "windows", 5 | Unknown = "unknown", 6 | } 7 | 8 | export const getDesktopOS = (): DesktopOS | undefined => { 9 | const userAgent = window.navigator.userAgent; 10 | 11 | if (userAgent.indexOf("Win") !== -1) return DesktopOS.Windows; 12 | else if (userAgent.indexOf("Mac") !== -1) return DesktopOS.MacOS; 13 | else if (userAgent.indexOf("Linux") !== -1) return DesktopOS.Linux; 14 | 15 | return DesktopOS.Unknown; 16 | }; 17 | -------------------------------------------------------------------------------- /packages/web/src/common/utils/draft/draft.util.test.ts: -------------------------------------------------------------------------------- 1 | import { Categories_Event } from "@core/types/event.types"; 2 | import { assembleDefaultEvent } from "../event.util"; 3 | 4 | jest.mock("@web/auth/auth.util", () => ({ 5 | getUserId: jest.fn().mockResolvedValue("mock-user-id"), 6 | })); 7 | describe("assembleDefaultEvent", () => { 8 | it("should include dates for someday event when provided", async () => { 9 | const startDate = "2024-01-01"; 10 | const endDate = "2024-01-07"; 11 | const eventWithDates = await assembleDefaultEvent( 12 | Categories_Event.SOMEDAY_WEEK, 13 | startDate, 14 | endDate, 15 | ); 16 | 17 | expect(eventWithDates).toHaveProperty("startDate", startDate); 18 | expect(eventWithDates).toHaveProperty("endDate", endDate); 19 | }); 20 | it("dates should be empty for someday event when not provided", async () => { 21 | const eventWithoutDates = await assembleDefaultEvent( 22 | Categories_Event.SOMEDAY_WEEK, 23 | ); 24 | 25 | expect(eventWithoutDates).toHaveProperty("startDate", ""); 26 | expect(eventWithoutDates).toHaveProperty("endDate", ""); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /packages/web/src/common/utils/event-target-visibility.util.ts: -------------------------------------------------------------------------------- 1 | import { SyntheticEvent } from "react"; 2 | 3 | /** 4 | * onEventTargetVisibility 5 | * Monitors the visibility of an event target 6 | * and executes a callback when the visibility changes. 7 | * See this link for additional context: https://github.com/SwitchbackTech/compass/pull/520#discussion_r2148965613 8 | * @param callback 9 | * @param visible execute the callback when the target is hidden or visible 10 | * @param visible defaults to false 11 | * @returns 12 | */ 13 | export const onEventTargetVisibility = 14 | (callback: () => void, visible = false) => 15 | ( 16 | event: SyntheticEvent, 17 | ) => { 18 | const observer = new IntersectionObserver(([entry]) => { 19 | if (entry.isIntersecting !== visible) return; 20 | 21 | observer.disconnect(); 22 | callback(); 23 | }); 24 | 25 | observer.observe(event.currentTarget); 26 | }; 27 | -------------------------------------------------------------------------------- /packages/web/src/common/utils/event.util.test.ts: -------------------------------------------------------------------------------- 1 | import { isEventInRange } from "./event.util"; 2 | 3 | describe("isEventInRange", () => { 4 | it("returns true if event is in range", () => { 5 | const event = { start: "2022-03-15", end: "2022-03-15" }; 6 | const dates = { 7 | start: "2022-03-14", 8 | end: "2022-03-19", 9 | }; 10 | expect(isEventInRange(event, dates)).toBe(true); 11 | }); 12 | it("returns false if event is not in range", () => { 13 | const event = { start: "2022-03-15", end: "2022-03-15" }; 14 | const dates = { 15 | start: "2022-03-16", 16 | end: "2022-03-19", 17 | }; 18 | expect(isEventInRange(event, dates)).toBe(false); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /packages/web/src/common/utils/grid.util.test.ts: -------------------------------------------------------------------------------- 1 | import { getLineClamp } from "./grid.util"; 2 | 3 | describe("getLineClamp", () => { 4 | it("uses a minimum value of 1", () => { 5 | expect(getLineClamp(-1)).toBe(1); 6 | expect(getLineClamp(0)).toBe(1); 7 | expect(getLineClamp(1)).toBe(1); 8 | expect(getLineClamp(1.818)).toBe(1); 9 | }); 10 | it("uses value larger than 1 when possible", () => { 11 | expect(getLineClamp(190.88)).toBeGreaterThan(1); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /packages/web/src/common/utils/mouse/mouse.util.ts: -------------------------------------------------------------------------------- 1 | import { MouseEvent } from "react"; 2 | 3 | export const isRightClick = (e: MouseEvent) => { 4 | return e.button === 2; 5 | }; 6 | 7 | export const isLeftClick = (e: MouseEvent) => { 8 | return e.button === 0; 9 | }; 10 | -------------------------------------------------------------------------------- /packages/web/src/common/utils/shortcut.util.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Command, WindowsLogo } from "@phosphor-icons/react"; 3 | import { DesktopOS, getDesktopOS } from "@web/common/utils/device.util"; 4 | 5 | export const getMetaKey = ({ size = 14 }: { size?: number } = {}) => { 6 | const desktopOS = getDesktopOS(); 7 | const isMacOS = desktopOS === DesktopOS.MacOS; 8 | 9 | return isMacOS ? : ; 10 | }; 11 | -------------------------------------------------------------------------------- /packages/web/src/common/validators/grid.event.validator.ts: -------------------------------------------------------------------------------- 1 | import { Schema_Event } from "@core/types/event.types"; 2 | import { GridEventSchema } from "../types/web.event.types"; 3 | 4 | export const validateGridEvent = (event: Schema_Event) => { 5 | const result = GridEventSchema.parse(event); 6 | return result; 7 | }; 8 | -------------------------------------------------------------------------------- /packages/web/src/common/validators/someday.event.validator.ts: -------------------------------------------------------------------------------- 1 | import { Schema_Event } from "@core/types/event.types"; 2 | import { 3 | Schema_SomedayEvent, 4 | SomedayEventSchema, 5 | } from "../types/web.event.types"; 6 | 7 | export const validateSomedayEvent = ( 8 | event: Schema_Event, 9 | ): Schema_SomedayEvent => { 10 | const result = SomedayEventSchema.parse(event); 11 | return result; 12 | }; 13 | 14 | export const validateSomedayEvents = ( 15 | events: Schema_Event[], 16 | ): Schema_SomedayEvent[] => { 17 | const results = events.map((event) => validateSomedayEvent(event)); 18 | return results; 19 | }; 20 | -------------------------------------------------------------------------------- /packages/web/src/components/AbsoluteOverflowLoader/AbsoluteOverflowLoader.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { AlignItems, JustifyContent, Props } from "@web/components/Flex/styled"; 3 | import { Styled, StyledSpinner } from "./styled"; 4 | 5 | export const AbsoluteOverflowLoader = (props: Props) => ( 6 | 11 | 12 | 13 | ); 14 | -------------------------------------------------------------------------------- /packages/web/src/components/AbsoluteOverflowLoader/index.ts: -------------------------------------------------------------------------------- 1 | export { AbsoluteOverflowLoader } from "./AbsoluteOverflowLoader"; 2 | -------------------------------------------------------------------------------- /packages/web/src/components/AbsoluteOverflowLoader/styled.ts: -------------------------------------------------------------------------------- 1 | import styled, { keyframes } from "styled-components"; 2 | import { Flex } from "@web/components/Flex"; 3 | 4 | export const Styled = styled(Flex)` 5 | position: absolute; 6 | width: 100%; 7 | height: 100%; 8 | top: 0; 9 | left: 0; 10 | backdrop-filter: blur(5px); 11 | `; 12 | 13 | const spinnerAnimation = keyframes` 14 | 0% { 15 | transform: rotate(0deg); 16 | } 17 | 100% { 18 | transform: rotate(360deg); 19 | } 20 | `; 21 | 22 | export const StyledSpinner = styled.div` 23 | margin: 60px auto; 24 | font-size: 4px; 25 | position: relative; 26 | text-indent: -9999em; 27 | border-top: 1.1em solid rgba(255, 255, 255, 0.2); 28 | border-right: 1.1em solid rgba(255, 255, 255, 0.2); 29 | border-bottom: 1.1em solid rgba(255, 255, 255, 0.2); 30 | border-left: 1.1em solid #ffffff; 31 | transform: translateZ(0); 32 | animation: ${spinnerAnimation} 1.1s infinite linear; 33 | 34 | border-radius: 50%; 35 | width: 10em; 36 | height: 10em; 37 | 38 | &:after { 39 | border-radius: 50%; 40 | width: 10em; 41 | height: 10em; 42 | } 43 | `; 44 | -------------------------------------------------------------------------------- /packages/web/src/components/CheckBox/CheckBox.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { Check } from "@phosphor-icons/react"; 3 | import { AlignItems, JustifyContent } from "@web/components/Flex/styled"; 4 | import { Styled, StyledPlaceholder } from "./styled"; 5 | 6 | export interface Props { 7 | isChecked?: boolean; 8 | onChange?: (isChecked: boolean) => void; 9 | color?: string; 10 | } 11 | 12 | export const CheckBox: React.FC = ({ 13 | isChecked, 14 | onChange, 15 | color = "black", 16 | ...props 17 | }) => { 18 | const [isInternallyChecked, setIsChecked] = useState(isChecked); 19 | 20 | const onClick = () => { 21 | if (onChange) { 22 | onChange(!isInternallyChecked); 23 | } 24 | 25 | if (isChecked === undefined) { 26 | setIsChecked(!isInternallyChecked); 27 | } 28 | }; 29 | 30 | useEffect(() => { 31 | setIsChecked(isChecked); 32 | }, [isChecked]); 33 | 34 | return ( 35 | 36 | {!isInternallyChecked ? ( 37 | 43 | ) : ( 44 | 45 | )} 46 | 47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /packages/web/src/components/CheckBox/index.ts: -------------------------------------------------------------------------------- 1 | export { CheckBox } from "./CheckBox"; 2 | -------------------------------------------------------------------------------- /packages/web/src/components/CheckBox/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { Flex } from "@web/components/Flex"; 3 | 4 | export const Styled = styled.div` 5 | cursor: pointer; 6 | width: 20px; 7 | height: 20px; 8 | color: ${({ color }) => color}; 9 | `; 10 | 11 | export const StyledPlaceholder = styled(Flex)` 12 | border: 2px solid ${({ color }) => color || "black"}; 13 | border-radius: 2px; 14 | height: 100%; 15 | width: 100%; 16 | `; 17 | -------------------------------------------------------------------------------- /packages/web/src/components/ContextMenu/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const PriorityContainer = styled.div` 4 | display: flex; 5 | justify-content: center; 6 | gap: 10px; 7 | padding: 10px; 8 | `; 9 | 10 | export const PriorityCircle = styled.div<{ color: string; selected: boolean }>` 11 | width: 20px; 12 | height: 20px; 13 | border-radius: 50%; 14 | border: 2px solid ${({ color }) => color}; 15 | background-color: ${({ selected, color }) => 16 | selected ? color : "transparent"}; 17 | cursor: pointer; 18 | transition: all 0.2s ease-in-out; 19 | `; 20 | 21 | export const MenuItem = styled.li` 22 | padding: 10px 12px; 23 | cursor: pointer; 24 | user-select: none; 25 | font-size: 14px; 26 | color: #333; 27 | white-space: nowrap; 28 | display: flex; 29 | align-items: center; 30 | gap: 8px; 31 | border-bottom: 1px solid #eee; 32 | 33 | &:last-child { 34 | border-bottom: none; 35 | } 36 | 37 | &:hover { 38 | background-color: #f5f5f5; 39 | } 40 | `; 41 | 42 | export const MenuItemLabel = styled.span` 43 | font-size: ${({ theme }) => theme.text.size.l}; 44 | `; 45 | -------------------------------------------------------------------------------- /packages/web/src/components/Divider/Divider.tsx: -------------------------------------------------------------------------------- 1 | import React, { HTMLAttributes, useEffect, useState } from "react"; 2 | import { StyledDivider } from "./styled"; 3 | import { Props } from "./types"; 4 | 5 | export const Divider: React.FC> = ( 6 | props, 7 | ) => { 8 | const [toggled, toggle] = useState(false); 9 | 10 | useEffect(() => { 11 | toggle(true); 12 | }, []); 13 | 14 | return ; 15 | }; 16 | -------------------------------------------------------------------------------- /packages/web/src/components/Divider/index.ts: -------------------------------------------------------------------------------- 1 | import { Divider } from "./Divider"; 2 | 3 | export { Divider }; 4 | -------------------------------------------------------------------------------- /packages/web/src/components/Divider/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { getGradient } from "@web/common/styles/theme.util"; 3 | import { Props } from "./types"; 4 | 5 | export const StyledDivider = styled.div` 6 | background: ${({ color }) => getGradient(color)}; 7 | height: 2px; 8 | width: ${({ toggled, width }) => (toggled ? width || "100%" : 0)}; 9 | transition: ${({ theme }) => theme.transition.default}; 10 | `; 11 | -------------------------------------------------------------------------------- /packages/web/src/components/Divider/types.ts: -------------------------------------------------------------------------------- 1 | export interface Props { 2 | toggled?: boolean; 3 | width?: string; 4 | withAnimation?: boolean; 5 | } 6 | -------------------------------------------------------------------------------- /packages/web/src/components/Flex/index.ts: -------------------------------------------------------------------------------- 1 | import { Styled } from "./styled"; 2 | 3 | export const Flex = Styled; 4 | -------------------------------------------------------------------------------- /packages/web/src/components/Flex/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export enum FlexDirections { 4 | COLUMN = "column", 5 | COLUMN_REVERSE = "column-reverse", 6 | ROW = "row", 7 | ROW_REVERSE = "row-reverse", 8 | } 9 | 10 | export enum JustifyContent { 11 | CENTER = "center", 12 | LEFT = "left", 13 | SPACE_BETWEEN = "space-between", 14 | SPACE_AROUND = "space-around", 15 | } 16 | 17 | export enum AlignItems { 18 | CENTER = "center", 19 | BASELINE = "baseline", 20 | FLEX_END = "flex-end", 21 | FLEX_START = "flex-start", 22 | } 23 | 24 | export enum FlexWrap { 25 | WRAP = "wrap", 26 | NO_WRAP = "no-wrap", 27 | WRAP_REVERSE = "wrap-reverse", 28 | } 29 | 30 | export interface Props { 31 | direction?: FlexDirections; 32 | justifyContent?: JustifyContent; 33 | alignItems?: AlignItems; 34 | flexWrap?: FlexWrap; 35 | } 36 | 37 | export const Styled = styled.div` 38 | align-items: ${({ alignItems }) => alignItems || "start"}; 39 | display: flex; 40 | flex-direction: ${({ direction }) => direction || "row"}; 41 | flex-wrap: ${({ flexWrap }) => flexWrap || "nowrap"}; 42 | justify-content: ${({ justifyContent }) => justifyContent || "start"}; 43 | `; 44 | -------------------------------------------------------------------------------- /packages/web/src/components/Focusable/Focusable.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, Ref, forwardRef, useState } from "react"; 2 | import { 3 | ClassNamedComponent, 4 | UnderlinedInput, 5 | } from "@web/common/types/component.types"; 6 | import { Divider } from "@web/components/Divider"; 7 | 8 | export type Props = T & 9 | UnderlinedInput & { 10 | autoFocus?: boolean; 11 | Component: React.ComponentType; 12 | underlineColor?: string; 13 | }; 14 | 15 | const _Focusable = ( 16 | { 17 | autoFocus = false, 18 | Component, 19 | underlineColor, 20 | withUnderline, 21 | ...props 22 | }: Props, 23 | ref: Ref, 24 | ) => { 25 | const [isFocused, setIsFocused] = useState(false); 26 | const rest = props as unknown as T; 27 | 28 | return ( 29 | <> 30 | setIsFocused(true)} 34 | onBlur={() => { 35 | setIsFocused(false); 36 | }} 37 | autoFocus={autoFocus} 38 | /> 39 | {!!withUnderline && isFocused && } 40 | 41 | ); 42 | }; 43 | 44 | export const Focusable = forwardRef(_Focusable) as < 45 | T extends ClassNamedComponent, 46 | >( 47 | p: Props & { ref?: Ref }, 48 | ) => ReactElement; 49 | -------------------------------------------------------------------------------- /packages/web/src/components/GlobalStyle/index.ts: -------------------------------------------------------------------------------- 1 | import { GlobalStyle } from "./styled"; 2 | 3 | export { GlobalStyle }; 4 | -------------------------------------------------------------------------------- /packages/web/src/components/GlobalStyle/styled.ts: -------------------------------------------------------------------------------- 1 | import { createGlobalStyle } from "styled-components"; 2 | import { ZIndex } from "@web/common/constants/web.constants"; 3 | import { theme } from "@web/common/styles/theme"; 4 | 5 | export const GlobalStyle = createGlobalStyle` 6 | * { 7 | font-family: 'Rubik', Arial, sans-serif; 8 | box-sizing: border-box; 9 | } 10 | 11 | body { 12 | margin: 0; 13 | background-color: ${theme.color.bg.primary}; 14 | overflow-x: hidden; 15 | } 16 | 17 | .react-datepicker-popper { 18 | z-index: ${ZIndex.MAX}; 19 | } 20 | 21 | :focus-visible { 22 | outline: none; 23 | } 24 | `; 25 | -------------------------------------------------------------------------------- /packages/web/src/components/IconButton/IconButton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { IconButtonProps, StyledIconButton } from "./styled"; 3 | 4 | const IconButton: React.FC = ({ 5 | size = "medium", 6 | children: icon, 7 | ...props 8 | }) => { 9 | return ( 10 | 11 | {icon} 12 | 13 | ); 14 | }; 15 | 16 | export default IconButton; 17 | -------------------------------------------------------------------------------- /packages/web/src/components/IconButton/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export type IconButtonSize = "small" | "medium" | "large"; 4 | 5 | const sizeMap: Record = { 6 | small: 20, 7 | medium: 27, 8 | large: 34, 9 | }; 10 | 11 | export interface IconButtonProps 12 | extends React.ButtonHTMLAttributes { 13 | size?: IconButtonSize; 14 | } 15 | 16 | const buttonStyleReset = ` 17 | background: none; 18 | color: inherit; 19 | border: none; 20 | padding: 0; 21 | font: inherit; 22 | cursor: pointer; 23 | outline: inherit; 24 | `; 25 | 26 | export const StyledIconButton = styled.button` 27 | ${buttonStyleReset} 28 | display: flex; 29 | align-items: center; 30 | justify-content: center; 31 | transition: 32 | background-color 0.3s ease, 33 | transform 0.2s ease; 34 | font-size: ${({ size = "medium" }) => sizeMap[size]}px; 35 | 36 | &:hover { 37 | transform: scale(1.05); 38 | } 39 | 40 | &:active { 41 | transform: scale(0.95); 42 | } 43 | 44 | &:disabled { 45 | opacity: 0.6; 46 | cursor: not-allowed; 47 | } 48 | `; 49 | -------------------------------------------------------------------------------- /packages/web/src/components/IconProvider/IconProvider.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { IconContext } from "@phosphor-icons/react"; 3 | 4 | export const IconProvider = ({ children }: { children: React.ReactNode }) => { 5 | return ( 6 | 11 | {children} 12 | 13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /packages/web/src/components/Icons/Calendar.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { CalendarDots } from "@phosphor-icons/react"; 3 | import { iconStyles } from "./styled"; 4 | 5 | export const CalendarIcon = styled(CalendarDots)` 6 | ${iconStyles} 7 | `; 8 | -------------------------------------------------------------------------------- /packages/web/src/components/Icons/Command.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { Command } from "@phosphor-icons/react"; 3 | import { iconStyles } from "./styled"; 4 | 5 | export const CommandIcon = styled(Command)` 6 | ${iconStyles} 7 | `; 8 | -------------------------------------------------------------------------------- /packages/web/src/components/Icons/List.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { List } from "@phosphor-icons/react"; 3 | 4 | export const StyledListIcon = styled(List)` 5 | color: ${({ theme }) => theme.color.text.light}; 6 | transition: filter 0.2s ease; 7 | 8 | &:hover { 9 | cursor: pointer; 10 | filter: brightness(130%); 11 | } 12 | `; 13 | -------------------------------------------------------------------------------- /packages/web/src/components/Icons/Repeat.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { Repeat } from "@phosphor-icons/react"; 3 | 4 | export const RepeatIcon = styled(Repeat)` 5 | transition: filter 0.2s ease; 6 | 7 | &:hover { 8 | filter: brightness(130%); 9 | } 10 | `; 11 | -------------------------------------------------------------------------------- /packages/web/src/components/Icons/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { Sidebar } from "@phosphor-icons/react"; 3 | import { iconStyles } from "./styled"; 4 | 5 | export const SidebarIcon = styled(Sidebar)` 6 | ${iconStyles} 7 | `; 8 | -------------------------------------------------------------------------------- /packages/web/src/components/Icons/Todo.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { CheckCircle } from "@phosphor-icons/react"; 3 | import { iconStyles } from "./styled"; 4 | 5 | export const TodoIcon = styled(CheckCircle)` 6 | ${iconStyles} 7 | `; 8 | -------------------------------------------------------------------------------- /packages/web/src/components/Icons/X.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { X } from "@phosphor-icons/react"; 3 | 4 | export const StyledXIcon = styled(X)` 5 | transition: filter 0.2s ease; 6 | 7 | &:hover { 8 | filter: brightness(150%); 9 | } 10 | `; 11 | -------------------------------------------------------------------------------- /packages/web/src/components/Icons/icon.types.ts: -------------------------------------------------------------------------------- 1 | import { IconProps as PhosphorIconProps } from "@phosphor-icons/react"; 2 | 3 | export interface IconProps extends PhosphorIconProps { 4 | ref?: React.Ref; 5 | } 6 | -------------------------------------------------------------------------------- /packages/web/src/components/Icons/styled.ts: -------------------------------------------------------------------------------- 1 | import { css } from "styled-components"; 2 | import { IconProps } from "@phosphor-icons/react"; 3 | 4 | export const iconStyles = css` 5 | transition: filter 0.2s ease; 6 | 7 | &:hover { 8 | filter: brightness(130%); 9 | } 10 | `; 11 | -------------------------------------------------------------------------------- /packages/web/src/components/Input/Input.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | ForwardRefRenderFunction, 3 | HTMLAttributes, 4 | Ref, 5 | forwardRef, 6 | } from "react"; 7 | import { 8 | ClassNamedComponent, 9 | UnderlinedInput, 10 | } from "@web/common/types/component.types"; 11 | import { Focusable } from "../Focusable/Focusable"; 12 | import { StyledInput, Props as StyledProps } from "./styled"; 13 | 14 | export interface Props 15 | extends ClassNamedComponent, 16 | UnderlinedInput, 17 | StyledProps, 18 | HTMLAttributes { 19 | autoFocus?: boolean; 20 | bgColor?: string; 21 | } 22 | 23 | const InputComponent: ForwardRefRenderFunction = ( 24 | { withUnderline = true, ...props }: Props, 25 | ref: Ref, 26 | ) => ( 27 | 33 | ); 34 | 35 | export const Input = forwardRef(InputComponent); 36 | -------------------------------------------------------------------------------- /packages/web/src/components/Input/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export interface Props { 4 | bgColor?: string; 5 | } 6 | 7 | export const StyledInput = styled.input` 8 | background-color: ${({ bgColor }) => bgColor}; 9 | border: none; 10 | height: 34px; 11 | font-size: ${({ theme }) => theme.text.size.l}; 12 | outline: none; 13 | width: 100%; 14 | 15 | ::placeholder { 16 | color: ${({ theme }) => theme.color.text.darkPlaceholder}; 17 | } 18 | 19 | &:hover { 20 | filter: brightness(87%); 21 | } 22 | `; 23 | -------------------------------------------------------------------------------- /packages/web/src/components/SpaceCharacter/SpaceCharacter.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const SpaceCharacter = () => <> ; 4 | -------------------------------------------------------------------------------- /packages/web/src/components/SpaceCharacter/index.ts: -------------------------------------------------------------------------------- 1 | import { SpaceCharacter } from "./SpaceCharacter"; 2 | 3 | export { SpaceCharacter }; 4 | -------------------------------------------------------------------------------- /packages/web/src/components/Text/index.ts: -------------------------------------------------------------------------------- 1 | import { StyledText } from "./styled"; 2 | 3 | export const Text = StyledText; 4 | -------------------------------------------------------------------------------- /packages/web/src/components/Textarea/Textarea.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | ForwardRefRenderFunction, 3 | ForwardedRef, 4 | RefObject, 5 | forwardRef, 6 | useRef, 7 | } from "react"; 8 | import { Focusable } from "@web/components/Focusable/Focusable"; 9 | import { StyledTextarea } from "./styled"; 10 | import { TextareaProps } from "./types"; 11 | 12 | const _Textarea: ForwardRefRenderFunction< 13 | HTMLTextAreaElement, 14 | TextareaProps 15 | > = ( 16 | { withUnderline = true, underlineColor, ...props }: TextareaProps, 17 | parentRef: ForwardedRef, 18 | ) => { 19 | const newRef = useRef(null); 20 | const ref = (parentRef || newRef) as RefObject; 21 | 22 | return ( 23 | 30 | ); 31 | }; 32 | 33 | export const Textarea = forwardRef(_Textarea); 34 | -------------------------------------------------------------------------------- /packages/web/src/components/Textarea/index.ts: -------------------------------------------------------------------------------- 1 | import { Textarea } from "./Textarea"; 2 | 3 | export { Textarea }; 4 | -------------------------------------------------------------------------------- /packages/web/src/components/Textarea/styled.ts: -------------------------------------------------------------------------------- 1 | import TextareaAutoSize from "react-textarea-autosize"; 2 | import styled from "styled-components"; 3 | import { TextareaProps } from "./types"; 4 | 5 | export const StyledTextarea = styled(TextareaAutoSize)` 6 | border: none; 7 | outline: none; 8 | font-weight: 600; 9 | width: 100%; 10 | resize: none; 11 | 12 | ::placeholder { 13 | color: ${({ theme }) => theme.color.text.darkPlaceholder}; 14 | } 15 | `; 16 | -------------------------------------------------------------------------------- /packages/web/src/components/Textarea/types.ts: -------------------------------------------------------------------------------- 1 | import { TextareaAutosizeProps } from "react-textarea-autosize"; 2 | import { 3 | ClassNamedComponent, 4 | UnderlinedInput, 5 | } from "@web/common/types/component.types"; 6 | 7 | export interface TextareaProps 8 | extends UnderlinedInput, 9 | ClassNamedComponent, 10 | TextareaAutosizeProps { 11 | heightFitsContent?: boolean; 12 | } 13 | -------------------------------------------------------------------------------- /packages/web/src/components/Tooltip/Description/TooltipDescription.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from "react"; 2 | import { Text } from "@web/components/Text"; 3 | 4 | interface Props { 5 | description: string; 6 | } 7 | 8 | export const TooltipDescription: FC = ({ description }) => { 9 | return ( 10 | 11 | {description} 12 | 13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /packages/web/src/components/Tooltip/Description/index.ts: -------------------------------------------------------------------------------- 1 | import { TooltipDescription } from "./TooltipDescription"; 2 | 3 | export { TooltipDescription }; 4 | -------------------------------------------------------------------------------- /packages/web/src/components/Tooltip/index.ts: -------------------------------------------------------------------------------- 1 | import { Tooltip, TooltipContent, TooltipTrigger } from "./Tooltip"; 2 | 3 | export { Tooltip, TooltipContent, TooltipTrigger }; 4 | -------------------------------------------------------------------------------- /packages/web/src/components/Tooltip/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { Flex } from "@web/components/Flex"; 3 | 4 | export const StyledShortcutTip = styled(Flex)` 5 | background-color: ${({ theme }) => theme.color.fg.primary}; 6 | border: 1px solid ${({ theme }) => theme.color.bg.primary}; 7 | border-radius: ${({ theme }) => theme.shape.borderRadius}; 8 | padding: 5px 10px; 9 | `; 10 | -------------------------------------------------------------------------------- /packages/web/src/components/Tooltip/types.ts: -------------------------------------------------------------------------------- 1 | import type { Placement } from "@floating-ui/react"; 2 | 3 | export interface TooltipOptions { 4 | initialOpen?: boolean; 5 | placement?: Placement; 6 | open?: boolean; 7 | onOpenChange?: (open: boolean) => void; 8 | } 9 | -------------------------------------------------------------------------------- /packages/web/src/components/TooltipIconButton/TooltipIconButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from "react"; 2 | import IconButton from "@web/components/IconButton/IconButton"; 3 | import { 4 | Props as TooltipProps, 5 | TooltipWrapper, 6 | } from "@web/components/Tooltip/TooltipWrapper"; 7 | 8 | export interface Props { 9 | tooltipProps: Omit; 10 | buttonProps: Omit[0], "children">; 11 | icon?: ReactNode; 12 | /** Overrides the default icon button and injects whatever component you want instead */ 13 | component?: React.ReactElement; 14 | } 15 | 16 | const TooltipIconButton: React.FC = ({ 17 | tooltipProps, 18 | buttonProps, 19 | icon, 20 | component, 21 | }: Props) => { 22 | if (!component && !icon) { 23 | throw new Error("Either icon or component must be provided"); 24 | } 25 | 26 | return ( 27 | 28 | {component ? ( 29 | component 30 | ) : ( 31 | 32 | {icon} 33 | 34 | )} 35 | 36 | ); 37 | }; 38 | 39 | export default TooltipIconButton; 40 | -------------------------------------------------------------------------------- /packages/web/src/ducks/events/context/sync.context.ts: -------------------------------------------------------------------------------- 1 | export enum Sync_AsyncStateContextReason { 2 | SOCKET_EVENT_CHANGED = "SOCKET_EVENT_CHANGED", 3 | } 4 | -------------------------------------------------------------------------------- /packages/web/src/ducks/events/context/week.context.ts: -------------------------------------------------------------------------------- 1 | export enum Week_AsyncStateContextReason { 2 | WEEK_VIEW_CHANGE = "WEEK_VIEW_CHANGE", 3 | SOCKET_EVENT_CHANGED = "SOCKET_EVENT_CHANGED", 4 | } 5 | -------------------------------------------------------------------------------- /packages/web/src/ducks/events/event.api.ts: -------------------------------------------------------------------------------- 1 | import { AxiosPromise } from "axios"; 2 | import { 3 | Categories_Recur, 4 | Params_Events, 5 | Payload_Order, 6 | Schema_Event, 7 | } from "@core/types/event.types"; 8 | import { CompassApi } from "@web/common/apis/compass.api"; 9 | 10 | const EventApi = { 11 | create: (event: Schema_Event) => { 12 | return CompassApi.post(`/event`, event); 13 | }, 14 | delete: (_id: string) => { 15 | return CompassApi.delete(`/event/${_id}`); 16 | }, 17 | edit: ( 18 | _id: string, 19 | event: Schema_Event, 20 | params: { applyTo?: Categories_Recur }, 21 | ): AxiosPromise => { 22 | if (params?.applyTo) { 23 | return CompassApi.put(`/event/${_id}?applyTo=${params.applyTo}`, event); 24 | } 25 | 26 | return CompassApi.put(`/event/${_id}`, event); 27 | }, 28 | get: (params: Params_Events) => { 29 | if (params.someday) { 30 | return CompassApi.get( 31 | `/event?someday=true&start=${params.startDate}&end=${params.endDate}`, 32 | ); 33 | } else { 34 | return CompassApi.get( 35 | `/event?start=${params.startDate}&end=${params.endDate}`, 36 | ); 37 | } 38 | }, 39 | reorder: (order: Payload_Order[]) => { 40 | return CompassApi.put(`/event/reorder`, order); 41 | }, 42 | }; 43 | 44 | export { EventApi }; 45 | -------------------------------------------------------------------------------- /packages/web/src/ducks/events/selectors/draft.selectors.ts: -------------------------------------------------------------------------------- 1 | import { Categories_Event } from "@core/types/event.types"; 2 | import { RootState } from "@web/store"; 3 | 4 | export const selectDraft = (state: RootState) => state.events.draft.event; 5 | 6 | export const selectDraftActivity = (state: RootState) => 7 | state.events.draft.status?.activity; 8 | 9 | export const selectDraftCategory = (state: RootState) => 10 | state.events.draft.status?.eventType; 11 | 12 | export const selectDraftId = (state: RootState) => 13 | state.events.draft.event?._id; 14 | 15 | export const selectDraftStatus = (state: RootState) => 16 | state.events.draft.status; 17 | 18 | export const selectIsDNDing = (state: RootState) => 19 | state.events.draft.status?.activity === "dnd"; 20 | 21 | export const selectIsDrafting = (state: RootState) => 22 | state.events.draft.status?.isDrafting; 23 | 24 | export const selectIsDraftingExisting = (state: RootState) => 25 | state.events.draft.event?._id !== undefined; 26 | 27 | export const selectIsDraftingSomeday = (state: RootState) => 28 | state.events.draft.status?.eventType === Categories_Event.SOMEDAY_WEEK || 29 | state.events.draft.status?.eventType === Categories_Event.SOMEDAY_MONTH; 30 | -------------------------------------------------------------------------------- /packages/web/src/ducks/events/selectors/sync.selector.ts: -------------------------------------------------------------------------------- 1 | import { RootState } from "@web/store"; 2 | 3 | export const selectSyncState = (state: RootState) => state.sync; 4 | -------------------------------------------------------------------------------- /packages/web/src/ducks/events/selectors/view.selectors.ts: -------------------------------------------------------------------------------- 1 | import { RootState } from "@web/store"; 2 | 3 | export const selectDatesInView = (state: RootState) => state.view.dates; 4 | export const selectIsSidebarOpen = (state: RootState) => 5 | state.view.sidebar.isOpen; 6 | export const selectSidebarTab = (state: RootState) => state.view.sidebar.tab; 7 | 8 | export const selectHeaderNoteFocus = (state: RootState) => 9 | state.view.header.note.focus; 10 | -------------------------------------------------------------------------------- /packages/web/src/ducks/events/slices/someday.slice.types.ts: -------------------------------------------------------------------------------- 1 | import { Action } from "redux"; 2 | 3 | export interface Action_Someday_Reorder extends Action { 4 | payload: Payload_Someday_Reorder[]; 5 | } 6 | 7 | interface Payload_Someday_Reorder { 8 | _id: string; 9 | order: number; 10 | } 11 | -------------------------------------------------------------------------------- /packages/web/src/ducks/events/slices/sync.slice.ts: -------------------------------------------------------------------------------- 1 | import { PayloadAction, createSlice } from "@reduxjs/toolkit"; 2 | import { Sync_AsyncStateContextReason } from "@web/ducks/events/context/sync.context"; 3 | 4 | type Payload_TriggerFetch = { 5 | reason?: Sync_AsyncStateContextReason; 6 | }; 7 | 8 | interface State_Sync { 9 | isFetchNeeded: boolean; 10 | reason: null | Sync_AsyncStateContextReason; 11 | } 12 | 13 | const initialState: State_Sync = { 14 | isFetchNeeded: false, 15 | reason: null, 16 | }; 17 | 18 | export const syncSlice = createSlice({ 19 | name: "sync", 20 | initialState, 21 | reducers: { 22 | triggerFetch: { 23 | reducer: ( 24 | state, 25 | action: PayloadAction, 26 | ) => { 27 | state.isFetchNeeded = true; 28 | state.reason = action.payload?.reason || null; 29 | }, 30 | prepare: (payload?: Payload_TriggerFetch) => { 31 | return { 32 | payload: payload || ({} as Payload_TriggerFetch), 33 | }; 34 | }, 35 | }, 36 | resetIsFetchNeeded: { 37 | reducer: (state) => { 38 | state.isFetchNeeded = false; 39 | state.reason = null; 40 | }, 41 | prepare: () => { 42 | return { 43 | payload: {}, 44 | }; 45 | }, 46 | }, 47 | }, 48 | }); 49 | 50 | export const { triggerFetch, resetIsFetchNeeded } = syncSlice.actions; 51 | -------------------------------------------------------------------------------- /packages/web/src/ducks/events/slices/week.slice.ts: -------------------------------------------------------------------------------- 1 | import { createAsyncSlice } from "@web/common/store/helpers"; 2 | import { Response_HttpPaginatedSuccess } from "@web/common/types/api.types"; 3 | import { Payload_NormalizedAsyncAction } from "@web/common/types/entity.types"; 4 | import { Action_DeleteEvent, Payload_GetEvents } from "../event.types"; 5 | 6 | export const getWeekEventsSlice = createAsyncSlice< 7 | Payload_GetEvents, 8 | Response_HttpPaginatedSuccess 9 | >({ 10 | name: "getWeekEvents", 11 | reducers: { 12 | convert: () => {}, 13 | delete: (state, action: Action_DeleteEvent) => { 14 | state.value.data = state.value.data.filter( 15 | (i: string) => i !== action.payload._id, 16 | ); 17 | }, 18 | insert: (state, action: { payload: string }) => { 19 | // payload is the event id 20 | if (state.value === null || state.value === undefined) { 21 | console.error("error: state.value needs to be initialized"); 22 | } else { 23 | state.value.data.push(action.payload); 24 | } 25 | }, 26 | replace: ( 27 | state, 28 | action: { payload: { oldWeekId: string; newWeekId: string } }, 29 | ) => { 30 | state.value.data = state.value.data.map((id: string) => { 31 | if (id === action.payload.oldWeekId) { 32 | return action.payload.newWeekId; 33 | } 34 | return id; 35 | }); 36 | }, 37 | }, 38 | }); 39 | -------------------------------------------------------------------------------- /packages/web/src/ducks/settings/selectors/settings.selectors.ts: -------------------------------------------------------------------------------- 1 | import { RootState } from "@web/store"; 2 | 3 | export const selectIsCmdPaletteOpen = (state: RootState) => 4 | state.settings.isCmdPaletteOpen; 5 | -------------------------------------------------------------------------------- /packages/web/src/ducks/settings/slices/settings.slice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from "@reduxjs/toolkit"; 2 | 3 | interface State_Settings { 4 | isCmdPaletteOpen: boolean; 5 | } 6 | 7 | const initialState: State_Settings = { 8 | isCmdPaletteOpen: false, 9 | }; 10 | 11 | export const settingsSlice = createSlice({ 12 | name: "settings", 13 | initialState, 14 | reducers: { 15 | closeCmdPalette: (state) => { 16 | state.isCmdPaletteOpen = false; 17 | }, 18 | openCmdPalette: (state) => { 19 | state.isCmdPaletteOpen = true; 20 | }, 21 | toggleCmdPalette: (state) => { 22 | state.isCmdPaletteOpen = !state.isCmdPaletteOpen; 23 | }, 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /packages/web/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SwitchbackTech/compass/b4d777dbb5bd83406b8d0ae2be5fd358917a09ac/packages/web/src/favicon.ico -------------------------------------------------------------------------------- /packages/web/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Compass 7 | 8 | 9 | 10 | 14 | 18 | 19 | 20 |
21 | 22 | 23 | -------------------------------------------------------------------------------- /packages/web/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import { App } from "./App"; 4 | 5 | const container = document.getElementById("root"); 6 | const root = createRoot(container); 7 | 8 | root.render(); 9 | -------------------------------------------------------------------------------- /packages/web/src/public/svg/circle.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /packages/web/src/routers/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { RouterProvider, createBrowserRouter } from "react-router-dom"; 3 | import { ProtectedRoute } from "@web/auth/ProtectedRoute"; 4 | import { UserProvider } from "@web/auth/UserContext"; 5 | import { ROOT_ROUTES } from "@web/common/constants/routes"; 6 | import SocketProvider from "@web/socket/SocketProvider"; 7 | import { CalendarView } from "@web/views/Calendar"; 8 | import { LoginView } from "@web/views/Login"; 9 | import { LogoutView } from "@web/views/Logout"; 10 | import { NotFoundView } from "@web/views/NotFound"; 11 | 12 | const router = createBrowserRouter([ 13 | { 14 | path: ROOT_ROUTES.ROOT, 15 | element: ( 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | ), 24 | }, 25 | { path: ROOT_ROUTES.LOGIN, element: }, 26 | { path: ROOT_ROUTES.LOGOUT, element: }, 27 | { path: "*", element: }, 28 | ]); 29 | 30 | export const RootRouter = () => { 31 | return ; 32 | }; 33 | -------------------------------------------------------------------------------- /packages/web/src/socket/SocketProvider.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import { useDispatch } from "react-redux"; 3 | import { Socket, io } from "socket.io-client"; 4 | import { EVENT_CHANGED } from "@core/constants/websocket.constants"; 5 | import { ServerToClientEvents } from "@core/types/websocket.types"; 6 | import { useUser } from "@web/auth/UserContext"; 7 | import { ENV_WEB } from "@web/common/constants/env.constants"; 8 | import { Sync_AsyncStateContextReason } from "@web/ducks/events/context/sync.context"; 9 | import { triggerFetch } from "@web/ducks/events/slices/sync.slice"; 10 | 11 | const SocketProvider = ({ children }: { children: ReactNode }) => { 12 | const dispatch = useDispatch(); 13 | const { userId } = useUser(); 14 | 15 | const socket: Socket = io(ENV_WEB.BACKEND_BASEURL, { 16 | withCredentials: true, 17 | query: { 18 | userId, 19 | }, 20 | }); 21 | 22 | socket.on("connect_error", (err) => { 23 | console.error("connect_error:", err); 24 | }); 25 | 26 | socket.on("disconnect", (reason) => { 27 | console.log("disconnected due to:", reason); 28 | }); 29 | 30 | socket.on(EVENT_CHANGED, () => { 31 | dispatch( 32 | triggerFetch({ 33 | reason: Sync_AsyncStateContextReason.SOCKET_EVENT_CHANGED, 34 | }), 35 | ); 36 | }); 37 | 38 | return children; 39 | }; 40 | 41 | export default SocketProvider; 42 | -------------------------------------------------------------------------------- /packages/web/src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { configureStore } from "@reduxjs/toolkit"; 2 | import { sagaMiddleware } from "@web/common/store/middlewares"; 3 | import { reducers } from "./reducers"; 4 | 5 | export const store = configureStore({ 6 | reducer: reducers, 7 | middleware: (getDefaultMiddleware) => 8 | getDefaultMiddleware().concat(sagaMiddleware), 9 | }); 10 | 11 | export type RootState = ReturnType; 12 | export type AppDispatch = typeof store.dispatch; 13 | -------------------------------------------------------------------------------- /packages/web/src/store/reducers.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from "redux"; 2 | import { draftSlice } from "@web/ducks/events/slices/draft.slice"; 3 | import { 4 | createEventSlice, 5 | deleteEventSlice, 6 | editEventSlice, 7 | eventsEntitiesSlice, 8 | getCurrentMonthEventsSlice, 9 | } from "@web/ducks/events/slices/event.slice"; 10 | import { getSomedayEventsSlice } from "@web/ducks/events/slices/someday.slice"; 11 | import { syncSlice } from "@web/ducks/events/slices/sync.slice"; 12 | import { viewSlice } from "@web/ducks/events/slices/view.slice"; 13 | import { getWeekEventsSlice } from "@web/ducks/events/slices/week.slice"; 14 | import { settingsSlice } from "@web/ducks/settings/slices/settings.slice"; 15 | 16 | const eventsReducer = combineReducers({ 17 | createEvent: createEventSlice.reducer, 18 | draft: draftSlice.reducer, 19 | deleteEvent: deleteEventSlice.reducer, 20 | editEvent: editEventSlice.reducer, 21 | entities: eventsEntitiesSlice.reducer, 22 | getCurrentMonthEvents: getCurrentMonthEventsSlice.reducer, 23 | getSomedayEvents: getSomedayEventsSlice.reducer, 24 | getWeekEvents: getWeekEventsSlice.reducer, 25 | }); 26 | 27 | export const reducers = { 28 | events: eventsReducer, 29 | settings: settingsSlice.reducer, 30 | sync: syncSlice.reducer, 31 | view: viewSlice.reducer, 32 | }; 33 | -------------------------------------------------------------------------------- /packages/web/src/store/store.hooks.ts: -------------------------------------------------------------------------------- 1 | import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"; 2 | import { AppDispatch, RootState } from "."; 3 | 4 | export const useAppDispatch: () => AppDispatch = useDispatch; 5 | export const useAppSelector: TypedUseSelectorHook = useSelector; 6 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/calendarView.types.ts: -------------------------------------------------------------------------------- 1 | import { Dayjs } from "dayjs"; 2 | 3 | export interface RootProps { 4 | component: { 5 | today: Dayjs; 6 | }; 7 | } 8 | 9 | export type Category_View = "current" | "pastFuture"; 10 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/components/Dedication/index.ts: -------------------------------------------------------------------------------- 1 | import { Dedication } from "./Dedication"; 2 | 3 | export { Dedication }; 4 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/components/Dedication/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { ZIndex } from "@web/common/constants/web.constants"; 3 | import { theme } from "@web/common/styles/theme"; 4 | import { Flex } from "@web/components/Flex"; 5 | 6 | export const modalStyles = { 7 | content: { 8 | background: theme.color.fg.primary, 9 | boxShadow: `0 4px 8px ${theme.color.panel.shadow}`, 10 | top: "50%", 11 | left: "50%", 12 | right: "auto", 13 | bottom: "auto", 14 | marginRight: "-50%", 15 | transform: "translate(-50%, -50%)", 16 | width: "59%", 17 | zIndex: ZIndex.MAX, 18 | }, 19 | }; 20 | 21 | export const StyledCaption = styled.div` 22 | font-size: 0.8rem; 23 | margin-left: 50px; 24 | padding-top: 30px; 25 | `; 26 | 27 | export const StyledDedicationModal = styled.div``; 28 | 29 | export const StyledDedicationText = styled.p` 30 | font-size: 1rem; 31 | `; 32 | 33 | export const StyledDerekQuoteContainer = styled(Flex)` 34 | align-items: center; 35 | justify-content: center; 36 | flex-direction: row; 37 | `; 38 | 39 | export const StyledDerekPic = styled.img` 40 | border-radius: 50%; 41 | box-shadow: 0 0 10px ${({ theme }) => theme.color.panel.shadow}; 42 | max-width: 100%; 43 | `; 44 | 45 | export const StyledDerekQuote = styled.blockquote` 46 | font-size: 1.5rem; 47 | font-style: italic; 48 | margin-right: 20px; 49 | `; 50 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/components/Draft/context/DraftContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | import { Actions_Draft } from "../hooks/actions/useDraftActions"; 3 | import { useDraftForm } from "../hooks/state/useDraftForm"; 4 | import { Setters_Draft, State_Draft_Local } from "../hooks/state/useDraftState"; 5 | 6 | export type Props_DraftForm = ReturnType; 7 | 8 | export interface State_Draft extends State_Draft_Local { 9 | formProps: Props_DraftForm; 10 | } 11 | interface DraftContextValue { 12 | state: State_Draft; 13 | setters: Setters_Draft; 14 | actions: Actions_Draft; 15 | } 16 | 17 | export const DraftContext = createContext(null); 18 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/components/Draft/context/useDraftContext.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { DraftContext } from "./DraftContext"; 3 | 4 | export const useDraftContext = () => { 5 | const context = useContext(DraftContext); 6 | if (!context) { 7 | throw new Error("useDraftContext must be used within DraftProvider"); 8 | } 9 | return context; 10 | }; 11 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/components/Draft/sidebar/context/SidebarDraftContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | import { Actions_Sidebar } from "../hooks/useSidebarActions"; 3 | import { Setters_Sidebar, State_Sidebar } from "../hooks/useSidebarState"; 4 | 5 | interface SidebarDraftContextValue { 6 | state: State_Sidebar; 7 | setters: Setters_Sidebar; 8 | actions: Actions_Sidebar; 9 | } 10 | export const SidebarDraftContext = 11 | createContext(null); 12 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/components/Draft/sidebar/context/SidebarDraftProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from "react"; 2 | import { DateCalcs } from "@web/views/Calendar/hooks/grid/useDateCalcs"; 3 | import { Measurements_Grid } from "@web/views/Calendar/hooks/grid/useGridLayout"; 4 | import { useSidebarActions } from "../hooks/useSidebarActions"; 5 | import { useSidebarEffects } from "../hooks/useSidebarEffects"; 6 | import { useSidebarState } from "../hooks/useSidebarState"; 7 | import { SidebarDraftContext } from "./SidebarDraftContext"; 8 | 9 | interface Props { 10 | children: ReactNode; 11 | dateCalcs: DateCalcs; 12 | measurements: Measurements_Grid; 13 | } 14 | export const SidebarDraftProvider = ({ 15 | children, 16 | dateCalcs, 17 | measurements, 18 | }: Props) => { 19 | const { setters, state } = useSidebarState(measurements); 20 | const actions = useSidebarActions(dateCalcs, state, setters); 21 | useSidebarEffects(state, actions); 22 | 23 | return ( 24 | 25 | {children} 26 | 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/components/Draft/sidebar/context/useSidebarContext.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { SidebarDraftContext } from "./SidebarDraftContext"; 3 | 4 | export const useSidebarContext = (suppressContextError = false) => { 5 | const context = useContext(SidebarDraftContext); 6 | if (!context) { 7 | if (suppressContextError) { 8 | return null; 9 | } 10 | throw new Error( 11 | "useSidebarContext must be used within SidebarDraftProvider", 12 | ); 13 | } 14 | return context; 15 | }; 16 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/components/Draft/sidebar/hooks/useSidebarEffects.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { selectIsDNDing } from "@web/ducks/events/selectors/draft.selectors"; 3 | import { useAppSelector } from "@web/store/store.hooks"; 4 | import { Actions_Sidebar } from "./useSidebarActions"; 5 | import { State_Sidebar } from "./useSidebarState"; 6 | 7 | export const useSidebarEffects = ( 8 | state: State_Sidebar, 9 | actions: Actions_Sidebar, 10 | ) => { 11 | const isDNDing = useAppSelector(selectIsDNDing); 12 | const { closeForm, handleChange } = actions; 13 | 14 | useEffect(() => { 15 | handleChange(); 16 | }, [handleChange]); 17 | 18 | useEffect(() => { 19 | if (isDNDing && state.isSomedayFormOpen) { 20 | closeForm(); 21 | } 22 | }, [isDNDing, state.isSomedayFormOpen, closeForm]); 23 | }; 24 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/components/Event/Grid/GridEvent/index.ts: -------------------------------------------------------------------------------- 1 | import { GridEvent } from "./GridEvent"; 2 | 3 | export { GridEvent }; 4 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/components/Event/Grid/index.ts: -------------------------------------------------------------------------------- 1 | import { GridEvent } from "./GridEvent"; 2 | 3 | export { GridEvent }; 4 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/components/Grid/AllDayRow/index.ts: -------------------------------------------------------------------------------- 1 | import { AllDayRow } from "./AllDayRow"; 2 | 3 | export { AllDayRow }; 4 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/components/Grid/AllDayRow/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { Flex } from "@web/components/Flex"; 3 | import { 4 | GRID_MARGIN_LEFT, 5 | GRID_PADDING_BOTTOM, 6 | GRID_Y_START, 7 | SCROLLBAR_WIDTH, 8 | } from "@web/views/Calendar/layout.constants"; 9 | import { GRID_TIME_STEP } from "@web/views/Calendar/layout.constants"; 10 | import { Columns } from "../Columns/styled"; 11 | 12 | const gridHeight = `100% - (${GRID_Y_START}px + ${GRID_PADDING_BOTTOM}px)`; 13 | const gridRowHeight = `(${gridHeight}) / 11`; 14 | const interval = 60 / GRID_TIME_STEP; 15 | const allDayRowHeight = `${gridRowHeight} / ${interval}`; 16 | 17 | const gridWidth = `100% - ${SCROLLBAR_WIDTH}px`; 18 | 19 | export const StyledAllDayColumns = styled(Columns)` 20 | height: 100%; 21 | `; 22 | export const StyledAllDayRow = styled(Flex)<{ rowsCount: number }>` 23 | border-bottom: ${({ theme }) => `2px solid ${theme.color.gridLine.primary}`}; 24 | height: ${({ rowsCount }) => 25 | `calc(${allDayRowHeight} * 2 + ${ 26 | rowsCount * 2 || 1 27 | } * ${allDayRowHeight})`}; 28 | position: relative; 29 | width: calc(${gridWidth}); 30 | `; 31 | 32 | export const StyledEvents = styled.div` 33 | position: relative; 34 | height: 100%; 35 | width: 100%; 36 | margin-left: ${GRID_MARGIN_LEFT}px; 37 | `; 38 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/components/Grid/Columns/TimesColumn/TimesColumn.tsx: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import React, { useEffect, useMemo, useState } from "react"; 3 | import { 4 | getColorsByHour, 5 | getHourLabels, 6 | } from "@web/common/utils/web.date.util"; 7 | import { Text } from "@web/components/Text"; 8 | import { StyledDayTimes, StyledTimesLabel } from "./styled"; 9 | 10 | export const TimesColumn = () => { 11 | const [currentHour, setCurrentHour] = useState(dayjs().hour()); 12 | const [colors, setColors] = useState(null); 13 | const hourLabels = useMemo(() => getHourLabels(), []); 14 | 15 | useEffect(() => { 16 | const _colors = getColorsByHour(currentHour); 17 | setColors(_colors); 18 | }, [currentHour]); 19 | 20 | useEffect(() => { 21 | const interval = setInterval(() => { 22 | const hour = dayjs().hour(); 23 | if (hour !== currentHour) { 24 | setCurrentHour(hour); 25 | } 26 | }, 60000); 27 | return () => clearInterval(interval); 28 | }, [currentHour]); 29 | 30 | if (!colors) return null; 31 | 32 | return ( 33 | 34 | {hourLabels.map((label, index) => ( 35 | 36 | {label} 37 | 38 | ))} 39 | 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/components/Grid/Columns/TimesColumn/index.ts: -------------------------------------------------------------------------------- 1 | import { TimesColumn } from "./TimesColumn"; 2 | 3 | export { TimesColumn }; 4 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/components/Grid/Columns/TimesColumn/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { ZIndex } from "@web/common/constants/web.constants"; 3 | 4 | interface Props { 5 | color: string; 6 | } 7 | 8 | export const StyledTimesLabel = styled.div` 9 | color: ${({ color }) => color}; 10 | `; 11 | 12 | export const StyledDayTimes = styled.div` 13 | height: 100%; 14 | position: absolute; 15 | top: calc(100% / 11 + -5px); 16 | z-index: ${ZIndex.LAYER_1}; 17 | 18 | & > div { 19 | height: calc(100% / 11); 20 | 21 | & > span { 22 | display: block; 23 | } 24 | } 25 | `; 26 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/components/Grid/Columns/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { Flex } from "@web/components/Flex"; 3 | import { 4 | DIVIDER_GRID, 5 | EVENT_WIDTH_MINIMUM, 6 | } from "@web/views/Calendar/layout.constants"; 7 | import { GRID_MARGIN_LEFT } from "@web/views/Calendar/layout.constants"; 8 | 9 | export const Columns = styled(Flex)` 10 | position: absolute; 11 | width: calc(100% - ${GRID_MARGIN_LEFT}px); 12 | left: ${GRID_MARGIN_LEFT}px; 13 | `; 14 | 15 | export const StyledGridCol = styled.div<{ color: string }>` 16 | border-left: ${({ theme }) => 17 | `${DIVIDER_GRID}px solid ${theme.color.gridLine.primary}`}; 18 | background: ${({ color }) => color}; 19 | flex-basis: 100%; 20 | height: 100%; 21 | min-width: ${EVENT_WIDTH_MINIMUM}px; 22 | position: relative; 23 | `; 24 | 25 | export const StyledGridCols = styled(Columns)` 26 | height: calc(24 * 100% / 11); 27 | `; 28 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/components/Grid/MainGrid/index.ts: -------------------------------------------------------------------------------- 1 | import { MainGrid } from "./MainGrid"; 2 | 3 | export { MainGrid }; 4 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/components/Grid/grid.types.ts: -------------------------------------------------------------------------------- 1 | import { MutableRefObject } from "react"; 2 | 3 | export interface GridPosition { 4 | height: number; 5 | left: number; 6 | top: number; 7 | width: number; 8 | } 9 | 10 | export type Ref_Grid = MutableRefObject; 11 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/components/Grid/index.ts: -------------------------------------------------------------------------------- 1 | import { Grid } from "./Grid"; 2 | 3 | export { Grid }; 4 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/components/NowLine/NowLine.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { getCurrentPercentOfDay } from "@web/common/utils/grid.util"; 3 | import { StyledNowLine } from "./styled"; 4 | 5 | interface NowLineProps { 6 | width: number; 7 | } 8 | 9 | export const NowLine: React.FC = ({ width }) => { 10 | const [percentOfDay, setPercentOfDay] = useState(getCurrentPercentOfDay()); 11 | 12 | useEffect(() => { 13 | const interval = setInterval(() => { 14 | setPercentOfDay(getCurrentPercentOfDay()); 15 | }, 60000); 16 | return () => clearInterval(interval); 17 | }, []); 18 | 19 | return ( 20 | 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/components/NowLine/index.tsx: -------------------------------------------------------------------------------- 1 | import { NowLine } from "./NowLine"; 2 | 3 | export { NowLine }; 4 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/components/NowLine/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { ZIndex } from "@web/common/constants/web.constants"; 3 | import { blueGradient } from "@web/common/styles/theme.util"; 4 | 5 | interface StyledNowLineProps { 6 | width: number; 7 | top: number; 8 | } 9 | 10 | export const StyledNowLine = styled.div` 11 | background: ${blueGradient}; 12 | height: 1px; 13 | position: absolute; 14 | top: ${({ top }) => top}%; 15 | width: ${({ width }) => width}%; 16 | z-index: ${ZIndex.LAYER_2}; 17 | `; 18 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/components/Sidebar/MonthTab/MonthTab.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { WeekProps } from "@web/views/Calendar/hooks/useWeek"; 3 | import { SidebarContent } from "../SomedayTab/styled"; 4 | import { SidebarMonthPicker } from "./MonthPicker/SidebarMonthPicker"; 5 | import { SubCalendarList } from "./SubCalendarList/SubCalendarList"; 6 | 7 | export interface Props { 8 | isCurrentWeek: boolean; 9 | monthsShown?: number; 10 | setStartOfView: WeekProps["state"]["setStartOfView"]; 11 | weekStart: WeekProps["component"]["startOfView"]; 12 | } 13 | 14 | export const MonthTab: React.FC = ({ 15 | isCurrentWeek, 16 | monthsShown, 17 | setStartOfView, 18 | weekStart, 19 | }) => { 20 | return ( 21 | 22 | 28 | 29 | 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/components/Sidebar/MonthTab/SubCalendarList/SubCalendarList.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from "react"; 2 | import { theme } from "@web/common/styles/theme"; 3 | import { Divider } from "@web/components/Divider"; 4 | import { Text } from "@web/components/Text"; 5 | import { 6 | CalendarLabel, 7 | CalendarList, 8 | CalendarListContainer, 9 | } from "../../styled"; 10 | 11 | export const SubCalendarList: FC = () => { 12 | return ( 13 | <> 14 | 19 | 20 | 21 | Calendars 22 | 23 | 24 | 25 | 31 | 32 | primary 33 | 34 | 35 | 36 | 37 | 38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/components/Sidebar/MonthTab/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { ID_DATEPICKER_SIDEBAR } from "@web/common/constants/web.constants"; 3 | import { Text } from "@web/components/Text"; 4 | 5 | export const MonthPickerContainer = styled.div` 6 | position: relative; 7 | 8 | & .${ID_DATEPICKER_SIDEBAR} { 9 | width: 100%; 10 | box-shadow: none; 11 | } 12 | `; 13 | 14 | export const StyledMonthName = styled(Text)` 15 | display: block; 16 | padding-top: 18px; 17 | `; 18 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/components/Sidebar/SidebarIconRow/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./SidebarIconRow"; 2 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/components/Sidebar/SomedayTab/SomedayEvents/DraggableSomedayEvent/DraggableSomedayEvents.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, memo } from "react"; 2 | import { Categories_Event, Schema_Event } from "@core/types/event.types"; 3 | import { ID_SOMEDAY_DRAFT } from "@web/common/constants/web.constants"; 4 | import { DraggableSomedayEvent } from "./DraggableSomedayEvent"; 5 | 6 | const _DraggableSomedayEvents: FC<{ 7 | category: Categories_Event; 8 | draft: Schema_Event | null; 9 | events: Schema_Event[]; 10 | isOverGrid: boolean; 11 | }> = ({ category, draft, events, isOverGrid }) => { 12 | return ( 13 | <> 14 | {events.map((event, index: number) => { 15 | const isDrafting = draft?._id === event._id; 16 | 17 | return ( 18 | 27 | ); 28 | })} 29 | 30 | ); 31 | }; 32 | 33 | export const DraggableSomedayEvents = memo(_DraggableSomedayEvents); 34 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/components/Sidebar/SomedayTab/SomedayEvents/SomedayEventContainer/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const StyledMigrateArrow = styled.span` 4 | padding-left: 7px; 5 | padding-right: 7px; 6 | 7 | &:hover { 8 | border-radius: 50%; 9 | background: ${({ theme }) => theme.color.bg.primary}; 10 | color: white; 11 | cursor: pointer; 12 | padding-right: 7px; 13 | padding-left: 7px; 14 | text-align: center; 15 | transition: background-color 0.4s; 16 | } 17 | `; 18 | export const StyledMigrateArrowInForm = styled(StyledMigrateArrow)` 19 | font-size: 27px; 20 | `; 21 | export const StyledRecurrenceText = styled.span` 22 | border: 1px solid ${({ theme }) => theme.color.border.primary}; 23 | border-radius: 2px; 24 | font-size: 10px; 25 | opacity: 0; 26 | transition: opacity 0.2s; 27 | width: 43px; 28 | 29 | &:hover { 30 | opacity: 1; 31 | transition: border ease-in 0.2s; 32 | } 33 | `; 34 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/components/Sidebar/SomedayTab/SomedayEvents/SomedayEventsContainer/AddSomedayEvent.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Text } from "@web/components/Text"; 3 | import { EventPlaceholder } from "@web/views/Calendar/components/Sidebar/styled"; 4 | 5 | export const AddSomedayEvent = () => { 6 | return ( 7 | 8 | + 9 | 10 | ); 11 | }; 12 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/components/Sidebar/SomedayTab/SomedayEvents/SomedayEventsContainer/Dropzone.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | // DropZone visually indicates the valid droppable area while dragging 4 | export const DropZone = styled.div<{ isActive: boolean }>` 5 | position: relative; 6 | transition: 7 | background-color 0.2s ease, 8 | border 0.2s ease; 9 | border-radius: ${({ theme }) => theme.shape.borderRadius}; 10 | border: ${({ isActive, theme }) => 11 | isActive 12 | ? `2px dashed ${theme.color.border.primary}` 13 | : `2px dashed transparent`}; 14 | background-color: ${({ isActive, theme }) => 15 | isActive ? theme.color.bg.secondary : "transparent"}; 16 | `; 17 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/components/Sidebar/SomedayTab/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { Flex } from "@web/components/Flex"; 3 | import { Text } from "@web/components/Text"; 4 | 5 | export const SidebarContainer = styled.div` 6 | flex: 1; 7 | `; 8 | 9 | export const SidebarContent = styled.div` 10 | flex: 1; 11 | display: flex; 12 | flex-direction: column; 13 | gap: 16px; 14 | overflow-y: auto; 15 | `; 16 | 17 | export const AddEventButton = styled(Text)` 18 | cursor: pointer; 19 | margin-right: 30px; 20 | &:hover { 21 | filter: brightness(160%); 22 | transition: filter 0.35s ease-out; 23 | } 24 | `; 25 | 26 | export const AddEventPlaceholder = styled.div` 27 | color: orange; 28 | `; 29 | 30 | export const SidebarHeader = styled(Flex)` 31 | margin: 10px 5px 20px 0px; 32 | `; 33 | 34 | export const SidebarSection = styled(Flex)` 35 | flex-direction: column; 36 | `; 37 | 38 | export const HeaderTitle = styled(Text)` 39 | margin: 0 10px; 40 | `; 41 | 42 | export const EventList = styled.div` 43 | padding: 20px; 44 | height: calc(100% - 67px); 45 | `; 46 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/components/Sidebar/sidebar.types.ts: -------------------------------------------------------------------------------- 1 | import { Priorities } from "@core/constants/core.constants"; 2 | 3 | export interface FutureEventsProps { 4 | shouldSetTopMargin?: boolean; 5 | } 6 | export interface PriorityFilter { 7 | [Priorities.RELATIONS]?: boolean; 8 | [Priorities.WORK]?: boolean; 9 | [Priorities.SELF]?: boolean; 10 | } 11 | 12 | export interface SectionProps { 13 | height?: string; 14 | } 15 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/components/TodayButton/TodayButton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { StyledTodayButton } from "./styled"; 3 | 4 | export const TodayButton = () => { 5 | return Today; 6 | }; 7 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/components/TodayButton/index.ts: -------------------------------------------------------------------------------- 1 | import { TodayButton } from "./TodayButton"; 2 | 3 | export { TodayButton }; 4 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/components/TodayButton/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { Flex } from "@web/components/Flex"; 3 | 4 | export const StyledTodayButton = styled(Flex)` 5 | color: ${({ theme }) => theme.color.text.light}; 6 | cursor: pointer; 7 | display: flex; 8 | font-size: ${({ theme }) => theme.text.size.l}; 9 | min-width: 80px; 10 | 11 | &:hover { 12 | filter: brightness(160%); 13 | transition: filter 0.35s ease-out; 14 | } 15 | `; 16 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/hooks/grid/useScroll.ts: -------------------------------------------------------------------------------- 1 | import { MutableRefObject, useCallback, useEffect } from "react"; 2 | import { getCurrentMinute } from "@web/common/utils/grid.util"; 3 | 4 | export const useScroll = ( 5 | timedGridRef: MutableRefObject, 6 | ) => { 7 | const scrollToNow = useCallback(() => { 8 | const rows = 11; 9 | const gridRowHeight = (timedGridRef.current?.clientHeight || 0) / rows; 10 | const minuteHeight = gridRowHeight / 60; 11 | 12 | const buffer = 150; 13 | const top = getCurrentMinute() * minuteHeight - buffer; 14 | 15 | if (timedGridRef.current) { 16 | timedGridRef.current.scroll({ 17 | top, 18 | behavior: "smooth", 19 | }); 20 | } 21 | }, [timedGridRef]); 22 | 23 | useEffect(() => { 24 | if (!timedGridRef.current) return; 25 | 26 | scrollToNow(); 27 | }, [scrollToNow, timedGridRef]); 28 | 29 | return { scrollToNow }; 30 | }; 31 | 32 | export type Util_Scroll = ReturnType; 33 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/hooks/mouse/useEventListener.ts: -------------------------------------------------------------------------------- 1 | import { MouseEvent, useEffect, useRef } from "react"; 2 | 3 | export const useEventListener = ( 4 | eventName: "mouseup" | "mousemove", 5 | handler: (e: MouseEvent) => void, 6 | element = window, 7 | ) => { 8 | const savedHandler = useRef<(e: MouseEvent) => void>(); 9 | // Update ref.current value if handler changes. 10 | // This allows our effect below to always get latest handler ... 11 | // ... without us needing to pass it in effect deps array ... 12 | // ... and potentially cause effect to re-run every render. 13 | 14 | useEffect(() => { 15 | savedHandler.current = handler; 16 | }, [eventName, handler]); 17 | 18 | useEffect(() => { 19 | const isSupported = element && element.addEventListener; 20 | 21 | if (!isSupported) return; 22 | 23 | const listener = (event: MouseEvent) => savedHandler.current(event); 24 | 25 | if (element === null) { 26 | console.log("element is null"); 27 | return; 28 | } 29 | element.addEventListener(eventName, listener); 30 | 31 | return () => { 32 | element.removeEventListener(eventName, listener); 33 | }; 34 | // removing 'element' passes some eventform tests 35 | // but fails to capture onmouseup events from useGridClick 36 | // }, [element, eventName]); 37 | }, [element, eventName]); 38 | }; 39 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/hooks/shortcuts/useFocusHotkey.ts: -------------------------------------------------------------------------------- 1 | import { useHotkeys } from "react-hotkeys-hook"; 2 | 3 | export const useFocusHotkey = ( 4 | callback: () => void, 5 | dependencies: unknown[] = [], 6 | ) => 7 | useHotkeys( 8 | "F", 9 | callback, 10 | { description: "focus", preventDefault: true }, 11 | dependencies, 12 | ); 13 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/hooks/usePreferences.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | export const usePreferences = () => { 4 | const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(true); 5 | const [isMonthWidgetOpen, setIsMonthWidgetOpen] = useState(false); 6 | 7 | const toggleMonthWidget = () => { 8 | setIsMonthWidgetOpen((open) => !open); 9 | }; 10 | 11 | const toggleSidebar = (target: "left") => { 12 | if (target === "left") { 13 | setIsLeftSidebarOpen((open) => !open); 14 | } 15 | }; 16 | 17 | return { 18 | isLeftSidebarOpen, 19 | isMonthWidgetOpen, 20 | toggleMonthWidget, 21 | toggleSidebar, 22 | }; 23 | }; 24 | 25 | export type Preferences = ReturnType; 26 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/hooks/useToday.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { useMemo } from "react"; 3 | 4 | export const useToday = () => { 5 | const getToday = (todayIndex: number) => { 6 | const today = dayjs(); 7 | if (today.get("day") === todayIndex) { 8 | return today; 9 | } 10 | return today; 11 | }; 12 | 13 | const todayIndex = dayjs().get("day"); 14 | const today = useMemo(() => getToday(todayIndex), [todayIndex]); 15 | 16 | return { today, todayIndex }; 17 | }; 18 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/index.tsx: -------------------------------------------------------------------------------- 1 | import { CalendarView } from "./Calendar"; 2 | 3 | export { CalendarView }; 4 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/layout.constants.ts: -------------------------------------------------------------------------------- 1 | export const AFTER_TMRW_MULTIPLE = 1.5; 2 | 3 | export const DIVIDER_GRID = 1; 4 | 5 | export const DRAFT_DURATION_MIN = 15; 6 | export const DRAFT_PADDING_BOTTOM = 3; 7 | 8 | export const EVENT_ALLDAY_HEIGHT = 20; 9 | export const EVENT_PADDING_RIGHT = 10; 10 | export const EVENT_WIDTH_MINIMUM = 80; 11 | 12 | export const FLEX_TODAY = 21.4; 13 | export const FLEX_TMRW = 18.6; 14 | export const FLEX_EQUAL = 14.285714285714286; // 100 / 7 15 | export const HEADER_HEIGHT = 40; 16 | 17 | export const PAGE_MARGIN_TOP = 35; 18 | export const PAGE_MARGIN_X = 25; 19 | 20 | export const SCROLLBAR_WIDTH = 8; 21 | 22 | export const WEEK_DAYS_HEIGHT = 26; 23 | export const WEEK_DAYS_MARGIN_Y = 22; 24 | 25 | export const GRID_PADDING_BOTTOM = 20; 26 | export const GRID_MARGIN_LEFT = 50; 27 | export const GRID_TIME_STEP = 15; 28 | export const GRID_X_START = PAGE_MARGIN_X + GRID_MARGIN_LEFT; 29 | export const GRID_Y_START = 30 | PAGE_MARGIN_TOP + HEADER_HEIGHT + WEEK_DAYS_HEIGHT + WEEK_DAYS_MARGIN_Y; 31 | export const GRID_X_PADDING_TOTAL = 32 | PAGE_MARGIN_X * 2 + GRID_MARGIN_LEFT + SCROLLBAR_WIDTH; 33 | 34 | export const SIDEBAR_MONTH_HEIGHT = 275; 35 | export const SIDEBAR_OPEN_WIDTH = 350; 36 | export const SIDEBAR_X_START = SIDEBAR_OPEN_WIDTH + GRID_X_START; 37 | -------------------------------------------------------------------------------- /packages/web/src/views/Calendar/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { Flex } from "@web/components/Flex"; 3 | import { PAGE_MARGIN_TOP, PAGE_MARGIN_X } from "./layout.constants"; 4 | 5 | export const Styled = styled(Flex)` 6 | height: 100vh; 7 | overflow: hidden; 8 | width: 100vw; 9 | `; 10 | 11 | export const StyledCalendar = styled(Flex)` 12 | background: ${({ theme }) => theme.color.bg.primary}; 13 | flex-grow: 1; 14 | height: 100%; 15 | margin: ${PAGE_MARGIN_TOP}px ${PAGE_MARGIN_X}px 0 ${PAGE_MARGIN_X}px; 16 | `; 17 | -------------------------------------------------------------------------------- /packages/web/src/views/CmdPalette/index.ts: -------------------------------------------------------------------------------- 1 | import CmdPalette from "./CmdPalette"; 2 | 3 | export { CmdPalette }; 4 | -------------------------------------------------------------------------------- /packages/web/src/views/Forms/EventForm/DateControlsSection/DateTimeSection/DatePickers/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { Flex } from "@web/components/Flex"; 3 | 4 | export const StyledDateFlex = styled(Flex)` 5 | width: 120px; 6 | `; 7 | -------------------------------------------------------------------------------- /packages/web/src/views/Forms/EventForm/DateControlsSection/DateTimeSection/form.datetime.util.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { YEAR_MONTH_DAY_FORMAT } from "@core/constants/date.constants"; 3 | import { getTimeOptionByValue } from "@web/common/utils/web.date.util"; 4 | 5 | export const getFormDates = (startDate: string, endDate: string) => { 6 | const start = dayjs(startDate); 7 | const startDateFormatted = start.format(YEAR_MONTH_DAY_FORMAT); 8 | const startTime = getTimeOptionByValue(start); 9 | 10 | const end = dayjs(endDate); 11 | const isOneDay = startDateFormatted === end.format(YEAR_MONTH_DAY_FORMAT); 12 | const displayEndDate = isOneDay 13 | ? startDateFormatted 14 | : end.subtract(1, "day").format(YEAR_MONTH_DAY_FORMAT); 15 | const _endDate = isOneDay ? start.toDate() : end.toDate(); 16 | const endTime = getTimeOptionByValue(end); 17 | 18 | return { 19 | startDate: start.toDate(), 20 | startTime, 21 | endDate: _endDate, 22 | displayEndDate, 23 | endTime, 24 | }; 25 | }; 26 | -------------------------------------------------------------------------------- /packages/web/src/views/Forms/EventForm/DateControlsSection/DateTimeSection/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { Flex } from "@web/components/Flex"; 3 | 4 | export const StyledDateTimeFlex = styled(Flex)``; 5 | 6 | export const StyledTimeFlex = styled(Flex)``; 7 | -------------------------------------------------------------------------------- /packages/web/src/views/Forms/EventForm/DateControlsSection/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Categories_Event } from "@core/types/event.types"; 3 | import { 4 | RecurrenceSection, 5 | RecurrenceSectionProps, 6 | } from "@web/views/Forms/EventForm/DateControlsSection/RecurrenceSection/RecurrenceSection"; 7 | import { 8 | DateTimeSection, 9 | Props as DateTimeSectionProps, 10 | } from "./DateTimeSection/DateTimeSection"; 11 | import { StyledControlsSection } from "./styled"; 12 | 13 | interface Props { 14 | dateTimeSectionProps: DateTimeSectionProps; 15 | recurrenceSectionProps: RecurrenceSectionProps; 16 | eventCategory: Categories_Event; 17 | } 18 | 19 | export const DateControlsSection = ({ 20 | dateTimeSectionProps, 21 | recurrenceSectionProps, 22 | eventCategory, 23 | }: Props) => { 24 | return ( 25 | 26 | 27 | {eventCategory === Categories_Event.TIMED && ( 28 | 29 | )} 30 | 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /packages/web/src/views/Forms/EventForm/DateControlsSection/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const StyledControlsSection = styled.div` 4 | margin-top: 15px; 5 | margin-bottom: 10px; 6 | margin-left: 40px; 7 | display: flex; 8 | flex-wrap: wrap; 9 | gap: ${({ theme }) => theme.spacing.s}; 10 | `; 11 | -------------------------------------------------------------------------------- /packages/web/src/views/Forms/EventForm/DeleteButton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Trash } from "@phosphor-icons/react"; 3 | import TooltipIconButton from "@web/components/TooltipIconButton/TooltipIconButton"; 4 | 5 | export const DeleteButton = ({ onClick }: { onClick: () => void }) => { 6 | return ( 7 | } 9 | buttonProps={{ "aria-label": "Delete Event [DEL]" }} 10 | tooltipProps={{ 11 | shortcut: "DEL", 12 | description: "Delete Event", 13 | onClick, 14 | }} 15 | /> 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /packages/web/src/views/Forms/EventForm/DuplicateButton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Copy } from "@phosphor-icons/react"; 3 | import TooltipIconButton from "@web/components/TooltipIconButton/TooltipIconButton"; 4 | 5 | export const DuplicateButton = ({ onClick }: { onClick: () => void }) => { 6 | return ( 7 | } 9 | buttonProps={{ "aria-label": "Duplicate Event [Meta+D]" }} 10 | tooltipProps={{ 11 | shortcut: "Meta+D", 12 | description: "Duplicate Event", 13 | onClick, 14 | }} 15 | /> 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /packages/web/src/views/Forms/EventForm/MigrateBackwardButton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import { getMetaKey } from "@web/common/utils/shortcut.util"; 4 | import { Text } from "@web/components/Text"; 5 | import TooltipIconButton from "@web/components/TooltipIconButton/TooltipIconButton"; 6 | 7 | const StyledMigrateBackwardButton = styled.div` 8 | font-size: 25px; 9 | &:hover { 10 | cursor: pointer; 11 | } 12 | `; 13 | 14 | export const MigrateBackwardButton = ({ 15 | onClick, 16 | tooltipText = "Migrate Backward", 17 | }: { 18 | onClick: () => void; 19 | tooltipText: string; 20 | }) => { 21 | return ( 22 | 25 | {"<"} 26 | 27 | } 28 | buttonProps={{ "aria-label": tooltipText }} 29 | tooltipProps={{ 30 | description: tooltipText, 31 | onClick, 32 | shortcut: ( 33 | 34 | Ctrl + {getMetaKey()} + Left 35 | 36 | ), 37 | }} 38 | /> 39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /packages/web/src/views/Forms/EventForm/MigrateForwardButton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import { getMetaKey } from "@web/common/utils/shortcut.util"; 4 | import { Text } from "@web/components/Text"; 5 | import TooltipIconButton from "@web/components/TooltipIconButton/TooltipIconButton"; 6 | 7 | const StyledMigrateForwardButton = styled.div` 8 | font-size: 25px; 9 | &:hover { 10 | cursor: pointer; 11 | } 12 | `; 13 | 14 | export const MigrateForwardButton = ({ 15 | onClick, 16 | tooltipText = "Migrate Forward", 17 | }: { 18 | onClick: () => void; 19 | tooltipText: string; 20 | }) => { 21 | return ( 22 | 25 | {">"} 26 | 27 | } 28 | buttonProps={{ "aria-label": tooltipText }} 29 | tooltipProps={{ 30 | description: tooltipText, 31 | onClick, 32 | shortcut: ( 33 | 34 | Ctrl + {getMetaKey()} + Right 35 | 36 | ), 37 | }} 38 | /> 39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /packages/web/src/views/Forms/EventForm/MoveToSidebarButton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { getMetaKey } from "@web/common/utils/shortcut.util"; 3 | import { Text } from "@web/components/Text"; 4 | import { TooltipWrapper } from "@web/components/Tooltip/TooltipWrapper"; 5 | import { StyledMigrateArrowInForm } from "@web/views/Calendar/components/Sidebar/SomedayTab/SomedayEvents/SomedayEventContainer/styled"; 6 | 7 | export const MoveToSidebarButton = ({ onClick }: { onClick: () => void }) => { 8 | const getShortcut = () => { 9 | return ( 10 | 11 | {getMetaKey()} +{" SHIFT "} + {","} 12 | 13 | ); 14 | }; 15 | 16 | return ( 17 | 22 | {"<"} 23 | 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /packages/web/src/views/Forms/EventForm/PrioritySection/index.ts: -------------------------------------------------------------------------------- 1 | import { PrioritySection } from "./PrioritySection"; 2 | 3 | export { PrioritySection }; 4 | -------------------------------------------------------------------------------- /packages/web/src/views/Forms/EventForm/PrioritySection/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { Flex } from "@web/components/Flex"; 3 | 4 | export const StyledPriorityFlex = styled(Flex)` 5 | margin: 15px 0; 6 | `; 7 | -------------------------------------------------------------------------------- /packages/web/src/views/Forms/EventForm/RepeatSection/RepeatDialog/index.ts: -------------------------------------------------------------------------------- 1 | import { RepeatDialog } from "./RepeatDialog"; 2 | 3 | export { RepeatDialog }; 4 | -------------------------------------------------------------------------------- /packages/web/src/views/Forms/EventForm/RepeatSection/index.ts: -------------------------------------------------------------------------------- 1 | import { RepeatSection } from "./RepeatSection"; 2 | 3 | export { RepeatSection }; 4 | -------------------------------------------------------------------------------- /packages/web/src/views/Forms/EventForm/RepeatSection/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { Flex } from "@web/components/Flex"; 3 | 4 | export const StyledRepeatContainer = styled.div` 5 | margin-bottom: 10px; 6 | &:hover { 7 | cursor: pointer; 8 | } 9 | `; 10 | 11 | export const StyledRepeatRow = styled(Flex)` 12 | align-items: center; 13 | flex-direction: row; 14 | `; 15 | 16 | interface Props { 17 | hasRepeat: boolean; 18 | } 19 | 20 | export const StyledRepeatText = styled.span` 21 | border: 1px solid transparent; 22 | border-radius: ${({ theme }) => theme.shape.borderRadius}; 23 | font-size: ${({ theme }) => theme.text.size.m}; 24 | opacity: ${({ hasRepeat }) => !hasRepeat && 0.85}; 25 | padding: 2px 8px; 26 | 27 | &:focus, 28 | &:hover { 29 | border: ${({ hasRepeat, theme }) => 30 | !hasRepeat && `1px solid ${theme.color.border.primaryDark}`}; 31 | } 32 | `; 33 | 34 | export const StyledRepeatTextContainer = styled(Flex)` 35 | align-items: center; 36 | border: 1px solid transparent; 37 | border-radius: ${({ theme }) => theme.shape.borderRadius}; 38 | gap: 6px; 39 | justify-content: center; 40 | margin-right: 8px; 41 | padding: 2px 8px; 42 | 43 | &:focus, 44 | &:hover { 45 | border: 1px solid ${({ theme }) => theme.color.border.primaryDark}; 46 | filter: brightness(90%); 47 | transition: border ${({ theme }) => theme.transition.default} ease; 48 | } 49 | `; 50 | -------------------------------------------------------------------------------- /packages/web/src/views/Forms/EventForm/SaveSection/SaveSection.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Priority } from "@core/constants/core.constants"; 3 | import { getMetaKey } from "@web/common/utils/shortcut.util"; 4 | import { StyledSaveBtn } from "@web/components/Button/styled"; 5 | import { Text } from "@web/components/Text"; 6 | import { TooltipWrapper } from "@web/components/Tooltip/TooltipWrapper"; 7 | import { StyledSubmitRow } from "../styled"; 8 | 9 | interface Props { 10 | onSubmit: () => void; 11 | priority: Priority; 12 | } 13 | 14 | export const SaveSection: React.FC = ({ 15 | onSubmit: _onSubmit, 16 | priority, 17 | }) => { 18 | return ( 19 | 20 | 24 | {getMetaKey()} + Enter 25 | 26 | } 27 | description="Save event" 28 | > 29 | 36 | Save 37 | 38 | 39 | 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /packages/web/src/views/Forms/EventForm/SaveSection/index.ts: -------------------------------------------------------------------------------- 1 | import { SaveSection } from "./SaveSection"; 2 | 3 | export { SaveSection }; 4 | -------------------------------------------------------------------------------- /packages/web/src/views/Forms/EventForm/index.ts: -------------------------------------------------------------------------------- 1 | import { EventForm } from "./EventForm"; 2 | 3 | export { EventForm }; 4 | -------------------------------------------------------------------------------- /packages/web/src/views/Forms/EventForm/types.ts: -------------------------------------------------------------------------------- 1 | import { SetStateAction } from "react"; 2 | import { Priority } from "@core/constants/core.constants"; 3 | import { 4 | Categories_Event, 5 | Direction_Migrate, 6 | Schema_Event, 7 | } from "@core/types/event.types"; 8 | 9 | export interface FormProps { 10 | event: Schema_Event; 11 | category: Categories_Event; 12 | isOpen?: boolean; 13 | onClose: () => void; 14 | onCloseEventForm?: () => void; 15 | onConvert?: () => void; 16 | onDelete?: (eventId?: string) => void; 17 | onDuplicate?: (event: Schema_Event) => void; 18 | onMigrate?: ( 19 | event: Schema_Event, 20 | category: Categories_Event, 21 | direction: Direction_Migrate, 22 | ) => void; 23 | onSubmit: (event?: Schema_Event) => void; 24 | onSubmitEventForm?: (event: Schema_Event) => void; 25 | priority?: Priority; 26 | setEvent: (event: Schema_Event) => SetStateAction | void; 27 | weekViewRange: { 28 | startDate: string; 29 | endDate: string; 30 | }; 31 | } 32 | 33 | type EventField = 34 | | "title" 35 | | "description" 36 | | "startDate" 37 | | "endDate" 38 | | "priority"; 39 | export type SetEventFormField = ( 40 | field: Partial, 41 | value?: Schema_Event[EventField], 42 | ) => void; 43 | 44 | export interface StyledFormProps { 45 | isOpen?: boolean; 46 | priority?: Priority; 47 | title?: string; 48 | } 49 | -------------------------------------------------------------------------------- /packages/web/src/views/Forms/SomedayEventForm/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { ZIndex } from "@web/common/constants/web.constants"; 3 | 4 | interface FormContainerProps { 5 | strategy: "fixed" | "absolute"; 6 | left: number; 7 | top: number; 8 | } 9 | 10 | export const StyledFloatContainer = styled.div` 11 | position: ${({ strategy }) => strategy || "absolute"}; 12 | left: ${({ left }) => left}px; 13 | top: ${({ top }) => top}px; 14 | width: max-content; 15 | z-index: ${ZIndex.LAYER_3}; 16 | `; 17 | -------------------------------------------------------------------------------- /packages/web/src/views/Login/index.ts: -------------------------------------------------------------------------------- 1 | import { LoginView } from "./Login"; 2 | 3 | export { LoginView }; 4 | -------------------------------------------------------------------------------- /packages/web/src/views/Logout/index.ts: -------------------------------------------------------------------------------- 1 | import { LogoutView } from "./Logout"; 2 | 3 | export { LogoutView }; 4 | -------------------------------------------------------------------------------- /packages/web/src/views/Logout/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { Btn } from "@web/components/Button/styled"; 3 | 4 | export const StyledLogoutBtn = styled(Btn)` 5 | background: ${({ theme }) => theme.color.status.info}; 6 | height: 35px; 7 | min-width: 158px; 8 | padding: 0 8px; 9 | 10 | &:hover { 11 | filter: brightness(120%); 12 | transition: brightness 0.5s; 13 | } 14 | `; 15 | -------------------------------------------------------------------------------- /packages/web/src/views/NotFound/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useNavigate } from "react-router-dom"; 3 | import notFoundImg from "@web/assets/png/notFound.png"; 4 | import { ROOT_ROUTES } from "@web/common/constants/routes"; 5 | import { Text } from "@web/components/Text"; 6 | import { 7 | StyledBackButton, 8 | StyledNotFoundContainer, 9 | StyledNotFoundImg, 10 | } from "./styled"; 11 | 12 | export const NotFoundView = () => { 13 | const navigate = useNavigate(); 14 | 15 | const goHome = () => navigate(ROOT_ROUTES.ROOT); 16 | 17 | return ( 18 | 19 |
20 | 🏴‍☠️ Shiver me timbers! 21 |
22 | 23 |
24 | This isn't part of the app, matey 25 |
26 | 27 | 28 | Go back to your booty 29 | 30 | 31 | 32 |
33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /packages/web/src/views/NotFound/index.ts: -------------------------------------------------------------------------------- 1 | import { NotFoundView } from "./NotFound"; 2 | 3 | export { NotFoundView }; 4 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "**/*.test.ts", "**/__mocks__/**/*"] 4 | } 5 | --------------------------------------------------------------------------------