Skip to content

Latest commit

 

History

History
101 lines (61 loc) · 7.87 KB

README.md

File metadata and controls

101 lines (61 loc) · 7.87 KB

How to install

  1. Install all dependencies: npm run install:all
  2. Start server: npm run dev:server
  3. Migrate db: npm run dev:migrate
  4. Start frontend server: npm run dev

Your browser will open automatically on http://localhost:5173/

Optional

  1. Run tests: npm run test:browser or npm run test to run in headless mode.
  2. Run lint: npm run lint

Demo

I recommend to start with the short demo video (demo.mp4) to get a quick overview of the solution and all features.

AI Disclaimer

A major part of the solution was pre-generated by AI. Therefore, this solution might be a bit bigger than you expect. I use a set of models with a tailored prompting strategy to generate the initial solution. If you try a naive approach of giving AI the task PDF and ask for a solution or even a part of the solution, it won't work with high probability or will contain a lot of anti-patterns (e.g., useEffect usage to sync state). AI drafts allowed me to solve the task faster and deliver better user experience (UX) and developer experience (DX). Some utility files are completely generated by AI and may contain some "obvious" comments. However, there are some parts that could be more polished, e.g., some styles are unnecessary. Nevertheless, I think this is good enough for the task.

Architecture

I used the following principles to build the architecture of the project:

  1. Feature Sliced Design - I used a feature-sliced design to separate the project into different features. Each feature has its own folder with all the necessary components, hooks, and utils. This approach helps to scale the project and make it more maintainable.
  2. Co-location - I used co-location to keep all the related files in the same folder. For example, I keep the component, hooks, and utils in the same folder.
  3. Locality Of Behavior - I tried to keep the behavior of the components as local as possible. As a result, I have bigger files with isolated private components and hooks to avoid unnecessary file and context switching.

Component structure:

<App>
  <QuotesTablePage>
    <QuotesFilterPopover />
    <QuotesTable>
      <Table>
      <PaginationControls>
    </QuotesTable>
    <QuoteDetailsSheet>
    <CreateQuoteDialog>
      <ProductsTable />
    </CreateQuoteDialog>
  </QuotesTablePage>
</App>

I decided to use Shadcn UI as a highly configurable components library with Tailwind CSS to co-locate styles with markup. I chose vite as a build tool to have faster DX since it's more appropriate than Next.js for SPA. Under the hood, Shadcn table uses tanstack-table which provides convenient headless utilities to build tables. I also have a fast linter setup.

Data fetching

I used react-query to manage asynchronous state. I created custom hooks on top of useQuery, useSuspenseQuery and useMutation to unify handling various states (loading, re-fetching, error, empty, success) and avoiding double form submission.

I added custom utility components on top of React Suspense to reduce code duplication when handling loading and error states. This also allowed for better types without undefined checks.

I provided a service wrapper for interaction with the API for better isolation and testing. In development mode, it also provides small delays to better see loading states. With this delays you can easily spot when data is read from cache. I decided to use PocketBase client to interact with the API to have a more unified experience over raw HTTP requests.

Since the task requires performing server-side sorting, filtering, and pagination for quotes, it increased the complexity of the app and data fetching. Without this requirement, it would be possible to use tanstack-table to handle this logic in a performant way on the client side. Current implementation can be considered as a premature optimization. It also suffers from over-fetching since it fetches all details. There are also ways to improve caching layout, but it was not required by the task.

I was able to implement sorting, filtering, and pagination for products fully by using tanstack-table, providing rich UX.

I decided to implement auto-refetching when the user changes filters without any additional button clicks. It's a common pattern in modern web applications to provide better UX. I added debouncing to avoid unnecessary requests.

State management

I use react-query as the primary source of state to avoid state duplication and synchronization overhead. This state is duplicated for products inside QuoteDetailsSheet for performant client-side pagination, filtering, and sorting. For other use cases, I tried to use useState for local state and useContext for dependency injection. This approach worked well but slightly decreased updates complexity since it relies on queryClient and cache keys.

Error handling

My Suspense utility comes with error boundaries around components depending on server state. I implemented a custom ErrorBoundary component to provide more flexible error handling and recovery options for different types of failures.

I primarily rely on Sentry's global error handler to catch unhandled errors rather than implementing try-catch blocks in event handlers. While this approach provides good error tracking, it could be improved by adding more specific error handling for critical user interactions.

I decided not to use zod since it's a very simple project.

Monitoring

I added Sentry to the project to monitor errors and performance. I track unhandled errors and rendering profiles. Distributed tracing could be added to track performance fullstack. Sentry is enabled in the production build.

Performance optimization

The primary technique to avoid performance issues is to render just enough information. This is achieved through pagination. The task also required implementation of virtualized rendering. I think this is an overhead leading to worse UX and slowing down development. Therefore, it is only enabled when fetching 100 quotes.

I decided not to memoize anything since it should be done by the compiler in the near future, and I do not have a lot of re-renders in the app. As mentioned above, I also used debouncing for network requests.

Testing strategy

I follow the testing trophy mindset over the testing pyramid, which emphasizes focusing on integration tests that cover user workflows while minimizing unit tests and end-to-end tests. I try to minimize mocking and test user flows instead of implementation details.

In development, I prefer to run tests in the browser in parallel to avoid manual testing with tools like storybook or no tools at all, which is even worse. Therefore, I rely on TypeScript and component tests to achieve efficient quality control. I generated a set of tests that provide >80% test coverage, and most of them run in less than 100ms on my machine (M3 Mac Pro). It is also possible to make them run even faster on CI by switching the environment to bun and happy-dom, but I didn't implement it.

I also decided to generate a fake backend service on top of SQLite to prevent mocking. It might be overkill for this project since it uses PocketBase. However, it's a very flexible solution, especially for more complex fullstack projects in TypeScript since I can run backend functionality partially in the browser.

I implemented additional testing utils to make sure that tests resemble end user usage as closely as possible. It is possible to test individual components in a controlled environment and also write unit tests to speed up some edge case testing. However, I decided to skip it since I have time constraints.

The current testing approach maximizes ROI from writing tests and makes it more affordable than manual testing in some cases.

Bonus feature

If you press shift while clicking on quotes table headers, you can sort by multiple columns. Since I leveraged AI-generated code, it took the same time to implement this feature as to implement single column sorting.