Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions SOLUTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
## Strategy & Trade-offs

I focused on implementing the back-end first in order to start the front-end work with all endpoints working and their APIs defined, for an easy integration in the front-end.

### back-end

- I implemented the `loadReviews` logic first, processing and returning all the necessary data needed for any other flows, making the processing of this logic a bit longer but facilitating the implementation and usage of the ReviewsData object where needed. Even though it might not be needed in a controlled scenario like this, I implemented the logic to be resilient to malformed files or files that do not follow the expected schema, to avoid errors in case the source file is changed;
- I implemented the `handleAutocomplete` endpoint using a partial match strategy combined of a standardized return of the matched names. This strategy leads to a better UX - as all matching names are going to be displayed, either partially or fully - and eases the front-end implementation as all names are already standardized, but this makes the logic more inefficient as a trade-off. As the losses in speed are not noticeable, I decided to use the solution that leaves the client with a better experience;
- I implemented the `handleGetReviews` endpoint using a map with the college URL as a key and the reviews as a value, to facilitate the lookup and remove the need of multiple iterations through the college reviews data;
- I implemented unit tests for the shared functionalities and E2E tests for the exposed endpoint in order to leave the solution more reliable after changes.

### front-end

- I used a debounced-search strategy to fetch the matching college names after the user is done typing
- I used a virtual focus strategy to show focus on the elements of the college name list while the user still maintains focus on the input and can type at any moment, even while navigating the list
- I broke the pages into smaller components to make the code easier to read and separate responsibilities
- I used Niche's own design style to base the styles of the app

## AI usage

### back-end

In the back-end implementation, I used AI to write the code after I had defined what needed to be changed and where, mostly because I'm not as used to writing GO code as I am to NodeJS, but also to accelerate implementation. Then I reviewed, corrected and refined the generated code as needed.

Used prompts:

1. "In @back-end/reviews.go, implement the loadReviews function with the following logic:

- Open and read the data from the @back-end/data/niche_reviews.csv. In case the file does not exist or any errors happen when opening the file, log the error details in the console and raise the error;
- Read all the data from the file and create an array of colleges (using the College struct) with the read info (header not included). If any rows are missing an ID, name or URL skip it. If it is missing reviews, just save an empty array in the "reviews" property. In case any errors happen when reading the file, log the error details in the console and raise the error;
- If the file contains rows with different amount of columns an error should be returned
- If the file does not following the expected schema an error should be returned
- If the file has no rows besides the header nil should be returned
- Return an array of colleges data (using the College struct), an map of reviews (array) by college URL and by ID and an array of sorted college names, following the defined ReviewsData;
- Implement the code minimizing loops to avoid high complexity implementations and make the code clean and easy to read."

2. "Now create unit tests for the loadReviews function to assert the following test cases:

- Generic error to open the file -> error should be returned
- File does not exist -> error should be returned
- Generic error when reading the file -> error should be returned
- File containing rows with different amount of columns -> error should be returned
- File not following the expected schema -> error should be returned
- File has no rows besides the header -> nil should be returned, no error should be returned
- Valid file, containing only one row besides the header -> extracted ReviewsData should be returned containing the data of only one college
- Valid file, containing multiple row besides the header -> extracted ReviewsData should be returned containing the data of multiple colleges, no error should be returned

The unit tests should not depend on external integrations, create fixtures for the csv file as needed and mock external dependencies in order to test the scenarios"

3. "In @back-end/server.go, implement the handleGetReviews endpoint logic. The endpoint should:

- receive a college url as a query param, search for the reviews related to that url in the reviewsByCollegeURLMap contained in the ReviewsData object and return them
- return a 400 HTTP status code if the url is NOT provided in the query params
- return a 200 HTTP status code and an array of college reviews (using the CollegeReview struct) if the url is existent in the map
- return a 404 HTTP status code if the url is NOT existent in the map
- return a 404 HTTP status code if ReviewsData is nil"

4. "Now create e2e tests for the handleGetReviews endpoint with the following test cases:

- the endpoint should return a 400 HTTP status code if the url is NOT provided in the query params
- the endpoint should return a 404 HTTP status code if the ReviewsData object is nil
- the endpoint should return a 400 HTTP status code if the url is NOT provided in the query params AND if the the ReviewsData object is nil
- the endpoint should return a 404 HTTP status code if the url provided is valid, the ReviewsData object is NOT nil but the URL data is NOT existent in the map
- the endpoint should return a 200 HTTP status code and an array of college reviews correctly parsed if the url provided is valid, the ReviewsData object is NOT nil and the URL data is existent in the map"

5. "In @back-end/server.go, implement the handleAutocomplete endpoint logic. The endpoint should:

- receive a college name as a query param and return matching college names from the ReviewsData object. The check should be case insensitive and should validate for partial matching (e.g.: the given name if given "var", "harvard" should be matched)
- return a 400 HTTP status code if the name is NOT provided in the query params
- return a 200 HTTP status code and an array of strings with the matching names, whereas if there are no matching names an empty array should be returned"

6. "Now create e2e tests for the handleAutocomplete endpoint with the following test cases:

- the endpoint should return a 400 HTTP status code if the name is NOT provided in the query params

- the endpoint should return a 200 HTTP status code and an empty array if ReviewsData object is nil
- the endpoint should return a 200 HTTP status code and an array with one value for input in upper case and ReviewsData object with upper case names and full match
- the endpoint should return a 200 HTTP status code and an array with one value for input in lower case and ReviewsData object with lower case names and full match
- the endpoint should return a 200 HTTP status code and an array with one value for input with partial match
- the endpoint should return a 200 HTTP status code and an array with multiple values for input with partial match"

### front-end

I used AI to save time in the development, style the app following the Niche standards, and for some minor changes like the focus scroll functionality.

Prompts:

1. "Create separate files for the input component and the list component and import them in the page"
2. "Make the college list scroll so that the virtually focused college is always on display"
3. "Style this @front-end/src/app to use the same font, colors and styles used by https://www.niche.com/ and https://www.niche.com/colleges/search/best-colleges/ "

## What I would do next

If I were to improve this solution further I would:

- Implement Docker in both apps to facilitate running and deploying
- Implement a better folder structure in the back-end project to facilitate maintenance as the project grows
- Stop depending on a local csv file to load the data, replacing it by a DB for a more robust solution, and also only loading the data from the DB on request, instead of when booting up the back-end
- Add TailwindCSS as an external lib in the front-end to facilitate stylization
- Add TanStack Query as an external lib in the front-end to facilitate deboucing queries
- Add Axios as an external lib in the front-end to facilitate HTTP calls
- Implement E2E automated test (e.g. Cypress or Playwright)

## How to use

### Running the apps

In order to use the application from end-to-end, it is necessary to run both front-end and back-end at the same time. To do that, follow these steps:

In order to run the back-end, run the following commands

```bash
cd back-end
go run .
```

In order to run the front-end, run the following commands

```bash
cd front-end
npm run dev
```

or, if you want to run the app in production mode, run

```bash
cd front-end
npm run build
npm run start
```

With both applications running, you can open the front-end by accessing "localhost:3000" in your browser and then you are free to explore the solution!

In case you want to try out the back-end endpoints directly, you can make direct HTTP requests to "localhost:8080".

_Important_: In case port 8080 is already in use in your computer, you must change the port in both `back-end\main.go` and `front-end\src\app\page.tsx` to keep the integration from failing.

### Testing the apps

In order to run the automated tests in the back-end, run the following commands:

```bash
cd back-end
go test ./...
```

or, add -v for verbose output (one line per test):

```bash
cd back-end
go test -v ./...
```
132 changes: 125 additions & 7 deletions back-end/reviews.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,131 @@
package main

// ReviewsData holds the reviews data. You should modify this type.
type ReviewsData struct{}
import (
"encoding/csv"
"fmt"
"io"
"log"
"os"
"sort"
"strings"
)

var expectedCSVHeaders = []string{"COLLEGE_UUID", "COLLEGE_NAME", "COLLEGE_URL", "REVIEW_TEXT"}

type Review struct {
Text string `json:"text"`
}

type CollegeReview struct {
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Reviews []Review `json:"reviews"`
}

type ReviewsData struct {
reviewsByCollegeURLMap map[string][]CollegeReview
reviewsByCollegeIdMap map[string][]CollegeReview
sortedCollegeNames []string
collegeURLByNameMap map[string]string
}

func loadReviews() (*ReviewsData, error) {
// TODO: Implement this function to load reviews from the CSV file
// 1. Read the CSV file from data/niche_reviews.csv
// 2. Parse the CSV data to extract college names and reviews
// 3. Build and return a ReviewsData struct with the processed data
return loadReviewsFromFile("data/niche_reviews.csv", func(path string) (io.ReadCloser, error) {
return os.Open(path)
})
}

func loadReviewsFromFile(path string, openFn func(string) (io.ReadCloser, error)) (*ReviewsData, error) {
r, err := openFn(path)
if err != nil {
log.Printf("Error opening reviews CSV file: %v", err)
return nil, err
}
defer r.Close()
return parseReviewsCSV(r)
}

func parseReviewsCSV(r io.Reader) (*ReviewsData, error) {
rows, err := csv.NewReader(r).ReadAll()
if err != nil {
log.Printf("Error reading reviews CSV file: %v", err)
return nil, err
}

if len(rows) == 0 || !headersMatch(rows[0], expectedCSVHeaders) {
return nil, fmt.Errorf("CSV file does not follow the expected schema: expected headers %v", expectedCSVHeaders)
}

dataRows := rows[1:]
if len(dataRows) == 0 {
return nil, nil
}

reviewsByIdMap := make(map[string][]Review)
collegeByIdMap := make(map[string]CollegeReview)

for _, row := range dataRows {
id, name, url, reviewText := row[0], row[1], row[2], row[3]

if id == "" || name == "" || url == "" {
continue
}

if reviewText != "" {
reviewsByIdMap[id] = append(reviewsByIdMap[id], Review{Text: reviewText})
}

if _, seen := collegeByIdMap[id]; !seen {
collegeByIdMap[id] = CollegeReview{ID: id, Name: toTitleCase(name), URL: url}
}
}

reviewsByCollegeIdMap := make(map[string][]CollegeReview, len(collegeByIdMap))
reviewsByCollegeURLMap := make(map[string][]CollegeReview, len(collegeByIdMap))
sortedCollegeNames := make([]string, 0, len(collegeByIdMap))
collegeURLByNameMap := make(map[string]string, len(collegeByIdMap))

for id, college := range collegeByIdMap {
reviews := reviewsByIdMap[id]
if reviews == nil {
reviews = []Review{}
}
college.Reviews = reviews
reviewsByCollegeIdMap[id] = []CollegeReview{college}
reviewsByCollegeURLMap[college.URL] = []CollegeReview{college}
sortedCollegeNames = append(sortedCollegeNames, college.Name)
collegeURLByNameMap[college.Name] = college.URL
}

sort.Strings(sortedCollegeNames)

return &ReviewsData{
reviewsByCollegeURLMap: reviewsByCollegeURLMap,
reviewsByCollegeIdMap: reviewsByCollegeIdMap,
sortedCollegeNames: sortedCollegeNames,
collegeURLByNameMap: collegeURLByNameMap,
}, nil
}

func toTitleCase(s string) string {
words := strings.Fields(s)
for i, word := range words {
if len(word) > 0 {
words[i] = strings.ToUpper(string(word[0])) + strings.ToLower(word[1:])
}
}
return strings.Join(words, " ")
}

return nil, nil
func headersMatch(actual, expected []string) bool {
if len(actual) != len(expected) {
return false
}
for i, h := range expected {
if actual[i] != h {
return false
}
}
return true
}
Loading