Skip to content
Merged
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
131 changes: 124 additions & 7 deletions backend/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
import type { WithId } from "mongodb";
import type { Document, Filter, WithId } from "mongodb";
import { db } from "./db.js";

// The shape of a plant document in MongoDB (only the fields we read)
interface PlantDoc {
commonName: string;
Expand Down Expand Up @@ -32,26 +33,47 @@ interface GetPlantsArgs {
offset: number;
}

interface ExplorePlantsArgs {
search?: string;
sunlight?: string;
watering?: string;
careLevel?: string;
sort?: string;
limit?: number;
offset?: number;
}

const plants = db.collection<PlantDoc>("plants");

const typeDefs = `
type Query{
getPlants(limit: Int = 50, offset: Int = 0): [Plant]
explorePlants(
search: String
sunlight: String
watering: String
careLevel: String
sort: String
limit: Int = 12
offset: Int = 0
): PlantPage!
}
type PlantPage {
plants: [Plant!]!
totalCount: Int!
}
type Plant{
id:ID!
id: ID!
name: String!
imageURL:String
imageURL: String
scientificName: [String]
description: String
watering: String
sunlight: [String]
careLevel: String}
careLevel: String
}
`;

const toPlant = (doc: WithId<PlantDoc>): Plant => ({
Expand All @@ -65,6 +87,62 @@ const toPlant = (doc: WithId<PlantDoc>): Plant => ({
careLevel: doc.careLevel,
});

// Escape user input so it can't be interpreted as regex
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");

// Filter values come in as "full_sun", the database has "full sun"
const normalize = (s: string) => escapeRegex(s.replace(/_/g, " ").trim());

// Order from low to high.
const SORT_ORDERS = {
watering: ["minimum", "average", "frequent"],
careLevel: ["low", "medium", "high"],
};

// Position in the order list, unknown or missing values go last
const rankOf = (order: string[], value: unknown): Document => ({
$let: {
vars: { i: { $indexOfArray: [order, value] } },
in: { $cond: [{ $lt: ["$$i", 0] }, 999, "$$i"] },
},
});

// Builds the aggregation stages that handle sorting
const buildSortStages = (sort?: string): Document[] => {
switch (sort) {
case "name_desc":
return [{ $sort: { commonName: -1, _id: 1 } }];
case "watering_asc":
return [
{
$addFields: {
sortRank: rankOf(SORT_ORDERS.watering, { $toLower: "$watering" }),
},
},
{ $sort: { sortRank: 1, commonName: 1, _id: 1 } },
];
case "careLevel_asc":
// "Moderate" and "Medium" both exist in the data, treat them as equal
return [
{
$addFields: {
sortRank: rankOf(SORT_ORDERS.careLevel, {
$let: {
vars: { c: { $toLower: { $ifNull: ["$careLevel", ""] } } },
in: {
$cond: [{ $eq: ["$$c", "moderate"] }, "medium", "$$c"],
},
},
}),
},
},
{ $sort: { sortRank: 1, commonName: 1, _id: 1 } },
];
default:
return [{ $sort: { commonName: 1, _id: 1 } }];
}
};

const resolvers = {
Query: {
getPlants: async (
Expand Down Expand Up @@ -94,6 +172,45 @@ const resolvers = {
.toArray();
return docs.map(toPlant);
},

explorePlants: async (
_parent: unknown,
args: ExplorePlantsArgs,
): Promise<{ plants: Plant[]; totalCount: number }> => {
const filter: Filter<PlantDoc> = {};

if (args.search?.trim()) {
filter.commonName = {
$regex: escapeRegex(args.search.trim()),
$options: "i",
};
}
if (args.watering) {
filter.watering = new RegExp(`^${normalize(args.watering)}$`, "i");
}
if (args.careLevel) {
const level = normalize(args.careLevel);
// "Medium" and "Moderate" both exist in the data and mean the same
filter.careLevel = new RegExp(
level === "medium" ? "^(medium|moderate)$" : `^${level}$`,
"i",
);
}

const [docs, totalCount] = await Promise.all([
plants
.aggregate<WithId<PlantDoc>>([
{ $match: filter },
...buildSortStages(args.sort),
{ $skip: args.offset },
{ $limit: args.limit },
])
.toArray(),
plants.countDocuments(filter),
]);

return { plants: docs.map(toPlant), totalCount };
},
},
};

Expand Down
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-i18next": "^17.0.14",
"react-icons": "^5.7.0",
"react-router-dom": "^7.18.4"
},
"devDependencies": {
Expand Down
26 changes: 18 additions & 8 deletions frontend/src/i18n/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,18 @@
"hardCare": "high care"
},
"ExplorePlants": {
"title": "Explore Plants",
"search": "Search for plants",
"loading": "Loading plants...",
"error": "Could not load plants. Please try again later.",
"filters": "Filters",
"sort": "Sort",
"name_asc": "Name (A-Z)",
"name_desc": "Name (Z-A)",
"watering_asc": "Watering (low-high)",
"careLevel_asc": "Care (easy-hard)",
"reset": "Reset",
"noResults": "No results found",
"watering": "Watering needs",
"all": "All",
"minimum": "Minimum",
Expand All @@ -50,14 +61,13 @@
"full_sun": "Full sun",
"part_sun": "Part sun",
"part_shade": "Part shade",
"location": "Location",
"indoor": "Indoor",
"outdoor": "Outdoor",
"edibility": "Edibility",
"edible": "Edible",
"poison": "Poisonous",
"reset": "Reset",
"noResults": "No results found"
"careLevel": "Care level",
"medium": "Medium",
"high": "High",
"pagination": "Page navigation",
"pageOf": "Page {{page}} of {{totalPages}}",
"previous": "Previous",
"next": "Next"
},
"myMatchesPage": {
"guide": "These are your matches:"
Expand Down
26 changes: 18 additions & 8 deletions frontend/src/i18n/locales/no/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,18 @@
"hardCare": "krever mye stell"
},
"ExplorePlants": {
"title": "Utforsk planter",
"search": "Søk etter planter",
"loading": "Laster planter...",
"error": "Kunne ikke hente planter. Prøv igjen senere.",
"filters": "Filtre",
"sort": "Sorter",
"name_asc": "Navn (A-Z)",
"name_desc": "Navn (Z-A)",
"watering_asc": "Vanning (lite-mye)",
"careLevel_asc": "Stell (lett-krevende)",
"reset": "Nullstill",
"noResults": "Ingen resultater funnet",
"watering": "Vanningsbehov",
"all": "Alle",
"minimum": "Sjeldent",
Expand All @@ -50,14 +61,13 @@
"full_sun": "Full sol",
"part_sun": "Delvis sol",
"part_shade": "Delvis skygge",
"location": "Plassering",
"indoor": "Innendørs",
"outdoor": "Utendørs",
"edibility": "Spiselighet",
"edible": "Spiselig",
"poison": "Giftig",
"reset": "Nullstill",
"noResults": "Ingen resultater funnet"
"careLevel": "Stell",
"medium": "Middels",
"high": "Høy",
"pagination": "Sidenavigasjon",
"pageOf": "Side {{page}} av {{totalPages}}",
"previous": "Forrige",
"next": "Neste"
},
"myMatchesPage": {
"guide": "Dette er dine matcher:"
Expand Down
Loading
Loading