{"id":34522584,"url":"https://github.com/vxm5091/snackpot","last_synced_at":"2026-04-29T23:33:11.186Z","repository":{"id":226118493,"uuid":"767770097","full_name":"vxm5091/snackpot","owner":"vxm5091","description":"a React Native mobile application that helps your friend or co-worker group decide who should cover the next group expense","archived":false,"fork":false,"pushed_at":"2024-03-09T03:03:08.000Z","size":3512,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":1,"default_branch":"main","last_synced_at":"2026-04-04T12:35:14.526Z","etag":null,"topics":["graphql","nestjs","react-hook-form","react-relay","typescript"],"latest_commit_sha":null,"homepage":"","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/vxm5091.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null}},"created_at":"2024-03-05T21:41:48.000Z","updated_at":"2024-03-09T03:06:12.000Z","dependencies_parsed_at":"2024-03-06T00:53:09.412Z","dependency_job_id":null,"html_url":"https://github.com/vxm5091/snackpot","commit_stats":null,"previous_names":["vxm5091/snackpot"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/vxm5091/snackpot","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vxm5091%2Fsnackpot","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vxm5091%2Fsnackpot/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vxm5091%2Fsnackpot/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vxm5091%2Fsnackpot/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/vxm5091","download_url":"https://codeload.github.com/vxm5091/snackpot/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vxm5091%2Fsnackpot/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":32448400,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-29T22:27:22.272Z","status":"ssl_error","status_checked_at":"2026-04-29T22:10:49.234Z","response_time":110,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["graphql","nestjs","react-hook-form","react-relay","typescript"],"created_at":"2025-12-24T04:59:50.303Z","updated_at":"2026-04-29T23:33:11.179Z","avatar_url":"https://github.com/vxm5091.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# snackpot\n\nSnackpot is a React Native mobile application that helps your friend or\nco-worker group decide who should cover the next group expense. How? By making\nthe decision for you.\n\n# Getting Started\n\n**Requirements**\n\n| Tech       | Version |\n|------------|---------|\n| Node.js    | 16.14.0 |\n| TypeScript | \u003e= 4.0  |\n| Postgres   | *       |\n| Expo Go*   | *       |\n\n_Expo Go is available on App Store / Google Play. It's a great tool for running\nthe app in development mode on your device with no additional upstart._\n\n## Clone Repo\n\n```\ngit clone https://github.com/vxm5091/snackpot.git\n```\n\n## Copy environment files\n\nRun the below command from the root folder:\n\n```\ncp ./server/.env.example ./server/.env \u0026\u0026 cp ./app/.env.example ./app/.env\n```\n\nOpen the `app` environment file (`./app/.env`) and change the IP address to your\nlocal network.\n\n### Mac\n\n`Wifi settings -\u003e Details -\u003e IP address`\n\n## Install dependencies\n\n```\nyarn \u0026\u0026 cd app \u0026\u0026 npx expo install \u0026\u0026 cd ../server/ \u0026\u0026 yarn\n```\n\n## Run database migrations and seeders\n\n```\ncd ./server \u0026\u0026 yarn migration:up \u0026\u0026 yarn seeder:fresh\n```\n\nYou can find the seeder logic in `./server/src/seeders/DatabaseSeeder.ts`\u003cbr/\u003e\nThe server `.env` file also provides two toggles to generate more or fewer\nentities. See data model below for an explanation of the data logic.\n\n## Install Expo Go\n\nYou can download it from the App Store or Google Play. Expo Go will allow you to\nrun the app in development mode directly on your device.\n\n## Start both servers\n\n```\nyarn start\n```\n\nRun this from the root folder to start both development servers. In your\nterminal, you will see a QR code. This will link you directly to Expo Go.\n\n## Stack Overview\n\nBoth the frontend and backend are written in **Typescript**.\u003cbr/\u003e\nFrontend: React Native, React Relay, Expo.\u003cbr/\u003e\nBackend: Node.js, NestJS, GraphQL (Relay), MikroORM, Postgres.\n\n## Demo\n\nLet's review our problem again: we need a system for deciding, in a balanced\nfashion, whose turn it is next to pick up the group check.\n\nWith each order, **if the user pays, their balance goes up**. If they **receive,\ntheir balance goes down.**\n**The user with the lowest balance pays for the next order** -\u003e their balance\ngoes back up.\n\n\n\u003ctable\u003e\n  \u003ctr\u003e\n    \u003ctd\u003e\n      \u003cimg src=\"screenshots/home_recipient.jpeg\" alt=\"First Image Caption\" width=\"300\" /\u003e\n      \u003cbr/\u003e\n      \u003cem style=\"display:block; text-align:center;\"\u003eHome Screen - user not paying\u003c/em\u003e\n    \u003c/td\u003e\n    \u003ctd style=\"padding-left:20px;\"\u003e \u003c!-- Add space between images --\u003e\n      \u003cimg src=\"screenshots/home_payer.jpeg\" alt=\"Second Image Caption\" width=\"300\" /\u003e\n      \u003cbr/\u003e\n      \u003cem style=\"display:block; text-align:center;\"\u003eHome Screen - user is paying\u003c/em\u003e\n    \u003c/td\u003e\n  \u003c/tr\u003e\n\u003c/table\u003e\n\nThe Home screen shows an active order card for each group the user is in. If\nthere isn't an active order open, the user can start one. The payer will still\nbe chosen based on balance.\n\nThe highlighted green fields are the ones the user can edit. When the user is\nnot paying, they just have to input what they got. Filling in the price is\noptional for the receiving user in case the payer is the only one who goes to\npick up the order.\n\nWhen **the user is paying**, they can edit all the fields and make any\nadjustments.\n\n**Simulate transactions**\u003cbr/\u003e\nWhen starting a new order, the user's entry will be the only one. This\nessentially fills in all the other members' orders with dummy data. **Note**: if\nall the members already have entries, it won't generate new ones.\n\n**Simulate end order**\u003cbr/\u003e\nIn a production flow, only the payer can close out the order. That way, they can\nmake sure the amounts are right. Since we're in test mode, we want to simulate\nclosing the order and starting a new one.\n\n\n\u003ctable\u003e\n  \u003ctr\u003e\n    \u003ctd\u003e\n      \u003cimg src=\"screenshots/group_balance.jpeg\" alt=\"First Image Caption\" width=\"300\" /\u003e\n      \u003cbr/\u003e\n      \u003cem style=\"display:block; text-align:center;\"\u003eGroup Info\u003c/em\u003e\n    \u003c/td\u003e\n  \u003c/tr\u003e\n\u003c/table\u003e\n\nGroup info provides a deeper dive on group activity, as well as shows every\nmember's latest balance.\n\n\n\u003ctable\u003e\n  \u003ctr\u003e\n    \u003ctd\u003e\n      \u003cimg src=\"screenshots/balance_breakdown.jpeg\" alt=\"First Image Caption\" width=\"300\" /\u003e\n      \u003cbr/\u003e\n      \u003cem style=\"display:block; text-align:center;\"\u003eBalance breakdown\u003c/em\u003e\n    \u003c/td\u003e\n  \u003c/tr\u003e\n\u003c/table\u003e\n\nYou can **press on any row in the member balance table** to see a historical\nbreakdown of that user's transactions within the group.\n\n## Data model \u0026 Postgres\n\n![Postgres Schema](screenshots/postgres_schema.png)\n\n**Why SQL**?\n\n1. **Structured data**\u003cbr/\u003e\n   There are specific data points our data model needs in order to produce the\n   output that our users are looking for. For each transaction, we need to know\n   who the receiving group member is, what they got, how much it cost, and what\n   order it relates to. Based on the order, we know who paid. Now we can\n   calculate whose balance goes up, whose balance goes down, and by how\n   much.\u003cbr/\u003e\u003cbr/\u003e\n2. **Relational data**\u003cbr/\u003e\n   I think the diagram above mostly speaks for itself here. **Users** joins *\n   *groups**. Groups create **orders**, which are made up of **transactions**\n   between the user paying for the order, and other members of the\n   group.\u003cbr/\u003e\u003cbr/\u003e\n\n3. **Need for complex joins**\u003cbr/\u003e\n   There isn't much of a first-player mode to this app. It sets out to solve a\n   group problem. As such, `users-groups` is really the focal point of the data\n   model. A user's orders and transactions with other users is in the context of\n   the group (think Splitwise as opposed to Venmo). To accurately track a\n   member's\n   balance within the group, we need a structured data model that efficiently\n   joins\n   these entities\n\n**Order** vs **Transaction**\u003cbr/\u003e\nAn **order** is composed of **transactions** between the user whose turn it is\nto pay, and each member of the group who got an item. Since the **payer** is the\nsame for all transactions in an order, foreign key relationship is kept in\nthe _orders_ table.\n\n## NestJS + GraphQL + MikroORM\n\n**NestJS** is a Node.js framework. It provides the guardrails for building a\nclean and scalable server, as well as all the tools that one might need in the\nprocess.\n\nI chose **GraphQL** over a REST API firstly because of the relational aspect of\nthe data model discussed in the Postgres section above. The beauty of a GraphQL\napproach, especially during a rapid MVP / iteration stage, is that it\nremoves the\npressure of having to think through all the access patterns upfront, or having\nto add new endpoints as our client-side features evolve. Instead, we do the work\nupfront, and present the frontend with a blueprint of the data model. The client\nis then free to traverse that blueprint in any way.\n\nNestJS offers two ways of building GraphQL applications: code first or schema\nfirst. I chose code first, meaning the `schema.gql` file is generated based on\nour Typescript code, as opposed to vice versa. When combined with MikroORM, we\nget:\n\n- type safety across the entire backend without having to define additional\n  interfaces, etc.\n- Consistency across domains (eg.\n  compare `CreateTransactionInput`, `Transaction` GraphQL model,\n  and `TransactionEntity`. different domains, consistent design) and types (\n  every type is organized the same).\n\n## Relay\n\nRelay is a GraphQL specification that was developed by the same team at Meta\nthat developed GraphQL. It set out to solve two problems: pagination and\ncaching. On the client side, Meta developed React Relay, a framework used by\nthis app as well. See React Relay's intro to the GraphQL specification for an\nexplanation:\n\n[GraphQL Relay Spec](https://relay.dev/docs/guides/graphql-server-specification/)\n\nThe benefits we get from using React Relay with a Relay-compliant GraphQL\nAPI are significant.\n\n1. **Caching.**\u003cbr/\u003e\n   The core of the Relay spec is the **globally unique ID.** This allows\n   React Relay to cache each item reliably.\u003cbr/\u003e\u003cbr/\u003e\n2. **Fragment composition**\u003cbr/\u003e\n   Each component declares its own data dependencies. The\n   Relay compiler generates the relevant fragment (and Typescript type),\n   which the parent spreads as a fragment. Let's take a look at an example\n   from our code:\n\n```ts\n// UserAvatar.tsx\n\ninterface IProps extends AvatarProps {\n  _data: UserAvatar_data$key;\n}\n\nexport const UserAvatar: React.FC\u003cIProps\u003e = ({ _data, ...props }) =\u003e {\n  const data = useFragment(\n    graphql`\n      fragment UserAvatar_data on User {\n        firstName\n        lastName\n        avatarURL\n      }\n    `,\n    _data,\n  );\n\n//   rest of code\n};\n```\n\nSo these are the fields the `UserAvatar` component needs. Now here's a\nparent component:\n\n```tsx\n// Transaction.tsx\n\ninterface IProps {\n  _recipientData: Transaction_data$key;\n  //   ...\n}\n\nexport const Transaction: React.FC\u003cIProps\u003e = ({\n  _recipientData,\n  //   ...\n}) =\u003e {\n  const recipientData = useFragment(\n    graphql`\n      fragment Transaction_data on User {\n        ...UserAvatar_data\n        username\n      }\n    `,\n    _recipientData,\n  );\n  \n  return (\n    // ...\n    \u003cUserAvatar\n      _data={recipientData}\n    /\u003e\n    //  ...\n  )\n}\n```\n\nNotice what's happening here. `Transaction` declares its own data\ndependencies, and then spreads its children's dependencies as a fragment.\nFrom the perspective of `Transaction`, it just knows that `Avatar` needs its\ndata fragment. What fields are in that fragment is `Avatar`'s business.\n\nThis allows us to develop components in a truly modularized and declarative\nfashion.\n\n_Continuing list_\n\n3. **No overfetching**\u003cbr/\u003e\n   By following the fragment composition pattern above, these fragments can\n   be composed together into a single query that fetches all the required\n   data in one round trip. I usually do this at the screen route level.\n\n```tsx\nconst HomeScreenRoute = () =\u003e {\n  const [queryRef, loadQuery] = useQueryLoader\u003cHomeScreenQuery\u003e(\n    graphql`\n      query HomeScreenQuery {\n        me {\n          ...UserAvatar_data\n          groups {\n            edges {\n              node @required(action: THROW) {\n                id\n                group {\n                  node @required(action: THROW) {\n                    ...ActiveOrderCard_data\n                  }\n                }\n              }\n            }\n          }\n        }\n      }\n    `,\n  );\n  \n  useFocusEffect(\n    useCallback(() =\u003e {\n      loadQuery({}, { fetchPolicy: 'store-and-network' });\n    }, [loadQuery]),\n  );\n  \n  return (\n    \u003cSuspense fallback={\u003cCustomSkeleton /\u003e}\u003e\n      {queryRef \u0026\u0026 \u003cHomeScreen _queryRef={queryRef} /\u003e}\n    \u003c/Suspense\u003e\n  );\n};\n```\n\n4. **Re-rendering upon data update**\u003cbr/\u003e\n   For example, spreading a fragment in the mutation response will re-render\n   any component that relies on that fragment. More fundamentally, whenever\n   the data associated with a fragment is updated in the Relay store, that\n   will trigger a re-render.\n   \u003cbr/\u003e\u003cbr/\u003e\n5. **Render as you fetch**\u003cbr/\u003e\n   React Relay is designed to take advantage of the new concurrency paradigm\n   in React. Refer back to the `HomeScreenRoute` example above. By leveraging\n   `Suspense`, we can isolate loading states and produce a more responsive\n   and instantaneous user experience. Our main\n   focus on the frontend is defining the data logic, the\n   `Suspense` boundaries, and fallback components. React Relay handles\n   displaying the data that's currently available in the store (if any),\n   suspending the components that don't have any data in the store (mostly\n   on initial render), and updating the store upon receiving a response.\n   \u003cbr/\u003e\u003cbr/\u003e\n6. **Pagination**\u003cbr/\u003e\n   Pagination isn't necessarily a top priority for a v1.0 MVP because it'll\n   take at least a bit of time for people to use the app enough to build up\n   long lists of data. But that can quickly change. Pagination is especially\n   critical in a React Native application, where list performance can\n   degrade rapidly if not properly optimized. I'll expand more on this with\n   some examples once I build it in.\n\n## Challenges\n\n#### `ActiveOrderCard` Form\n\nDespite having only two fields, `itemName` and `itemPrice`, the trickiness\nwith this component is in managing the different possible scenarios. If a\ncell is empty below, that means it's not relevant in that scenario. _Note:\nwe don't care about the user's transaction when the user is also paying for\nthe order because it has no net impact on their group balance._\n\n| activeOrder\u003cbr/\u003e(bool) | userRole  | user txn\u003cbr/\u003ein the form? | user txn\u003cbr/\u003ein the database? | CTA                                                        | Editable\u003cbr/\u003eFields |\n|------------------------|-----------|---------------------------|-------------------------------|------------------------------------------------------------|---------------------|\n| false                  |           |                           |                               | `CreateOrderButton`                                        |                     |\n| true                   | payer     |                           |                               | `CompleteOrder`                                            | all                 |\n| true                   | recipient | false                     | false                         | `CreateTransactionButton` _label = \"Add item\"_             | user                |\n| true                   | recipient | true                      | false                         | `CreateTransactionButton` _label = \"Confirm\"               | user                |\n| true                   | recipient | true                      | true                          | `DeleteMyTransactionButton`\u003cbr/\u003e `UpdateTransactionButton` | user                |\n\n1. **Ensuring form validation**, with slightly different rules depending on the\n   user's role. If the user is\n   paying, we have to ensure that all rows are valid. When the user is\n   receiving, we have to ensure that just the user row is valid. Also,\n   `itemPrice` is optional for the recipient, but required for the payer.\n   \u003cbr/\u003e\n\nI solved this through a combination of things:\n\n- leveraged React Hook Form to manage form state at the order level.\n  `useFieldArray` allows us to deal with each row separately, plus provides\n  a convenient API for adding/removing rows, and handling row-level and\n  cell-level validation errors.\n- Made `Transaction` a controlled input wrapper in the event that the\n  transaction is in \"edit mode\". If the transaction is historical (ie\n  rendered by `HistoricalOrderCard`), then we simply display the values as\n  `\u003cText\u003e` components. In `Transaction`, I defined validation rules for\n  `itemName` and `itemPrice` depending on `userRole`, and adjusted styling\n  in case of validation errors.\n\n2. **Accurately determining the state of the user's row and reflecting the\n   relevant calls to action.**\n\n- When the user presses `Add item`, this\n  inserts a row into the form.\n- Now, the call to action is `Confirm`, which\n  posts the `createTransaction` mutation. Before posting the mutation\n  however, we to validate the user's row. In order to validate the user's\n  row, we have to know which row is the user's. I solved this by making the\n  `id` property on the user's Transaction object `temp` before the initial\n  mutation, and using the `userIndex` and\n  `userTransactionID` state variables. This allows us to find the form row\n  index and perform\n  validation during\n  the submission flow.\n- Once the user has a Transaction in the database, the value of `id` is no\n  longer `temp`. So here, I leveraged both `useDidMount` and `useDidUpdate`\n  hooks to attempt to update `userIndex` both on initial render and while\n  the form re-renders. This allows us to handle validating an update, or\n  deleting the user's transaction.\n\n## Assumptions / MVP shortcuts\n\n1. Ignoring tax. The assumption is that that piece balances out over time and\n   the focus is more so on facilitating the decision making behind whose turn it\n   is.\n2. No authentication. For ease of use, the USER_ID is hard coded on the server.\n\n# TODO / Next step improvements\n\n- Incorporate dataloader on the server side to fix GraphQL N+1 problem\n- Testing (e2e, unit tests on resolver / entities (backend) and form / balance\n  components (frontend))\n- auth\n- Explore more frictionless ways of automating the cost entry portion. After\n  all, who really wants to do expenses on a daily basis? (gamification, auto\n  complete suggestions based on past transactions)\n- pagination\n- server-side caching\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fvxm5091%2Fsnackpot","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fvxm5091%2Fsnackpot","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fvxm5091%2Fsnackpot/lists"}