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
3 changes: 2 additions & 1 deletion .prettierrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@
"singleQuote": true,
"semi": true,
"trailingComma": "all",
"printWidth": 80
"printWidth": 80,
"endOfLine": "auto"
}
162 changes: 160 additions & 2 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,66 @@ function readPaging(args: { limit?: number; offset?: number }) {
const offset = args.offset ?? 0;

if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {
throw new GraphQLError(`limit må være mellom 1 og ${MAX_LIMIT}`, {
throw new GraphQLError(`limit must be between 1 and ${MAX_LIMIT}`, {
extensions: { code: 'BAD_USER_INPUT' },
});
}
if (!Number.isInteger(offset) || offset < 0) {
throw new GraphQLError('offset kan ikke være negativ', {
throw new GraphQLError('offset cannot be negative', {
extensions: { code: 'BAD_USER_INPUT' },
});
}
return { limit, offset };
}

// Smakskartet viser prikker, ikke sider, så det får en egen grense.
// Flere enn ca. 300 prikker blir uleselig og tungt å tegne.
const TASTE_MAP_MAX = 300;

type TasteFilter = {
type?: string | null;
grape?: string | null;
country?: string | null;
year?: number | null;
minRating?: number | null;
minRatingCount?: number | null;
};

/**
* Lager WHERE-delen for smakskartet ut fra filtrene.
* Verdiene sendes alltid som parametere ($1, $2, ...), aldri rett inn i SQL,
* så brukerinput kan ikke endre spørringen (SQL-injeksjon).
*/
function tasteWhere(filter: TasteFilter = {}) {
const where = ['w.body IS NOT NULL', 'w.acidity IS NOT NULL'];
const params: unknown[] = [];
const add = (sql: string, value: unknown) => {
params.push(value);
where.push(sql.replace('?', `$${params.length}`));
};

if (filter.type) add('w.type = ?', filter.type);
if (filter.country) add('c.name = ?', filter.country);
if (filter.grape) {
add(
`EXISTS (
SELECT 1 FROM wine_grapes wg
JOIN grapes g ON g.id = wg.grape_id
WHERE wg.wine_id = w.id AND g.name = ?
)`,
filter.grape,
);
}
// @> bruker GIN-indeksen wines_vintages_idx
if (filter.year != null) add('w.vintages @> ARRAY[?]::int[]', filter.year);
if (filter.minRating != null) add('w.avg_rating >= ?', filter.minRating);
if (filter.minRatingCount != null) {
add('w.rating_count >= ?', filter.minRatingCount);
}

return { sql: where.join(' AND '), params };
}

const typeDefs = `#graphql
type Wine {
Expand Down Expand Up @@ -67,6 +115,24 @@ const typeDefs = `#graphql
wineCount: Int!
}
# Filtre for Explore by taste. Alle er valgfrie; det som mangler, filtreres ikke på.
input TasteFilter {
type: String
grape: String
country: String
year: Int
minRating: Float
minRatingCount: Int
}
# Verdiene til nedtrekksmenyene i filterpanelet på Explore by taste
type TasteOptions {
types: [String!]!
countries: [String!]!
grapes: [String!]!
years: [Int!]!
}
type Query {
health: String!
Expand All @@ -85,6 +151,11 @@ const typeDefs = `#graphql
limit: Int = 20
offset: Int = 0
): WinePage!
# Viner til smakskartet. Bare viner med både fylde og syre kommer med.
tasteMap(filter: TasteFilter, limit: Int = 150): WinePage!
tasteOptions: TasteOptions!
}
`;

Expand Down Expand Up @@ -273,6 +344,93 @@ const resolvers = {
totalCount: countResult.rows[0].count,
};
},

tasteMap: async (
_: unknown,
args: { filter?: TasteFilter | null; limit?: number },
) => {
const limit = args.limit ?? 150;
if (!Number.isInteger(limit) || limit < 1 || limit > TASTE_MAP_MAX) {
throw new GraphQLError(`limit must be between 1 and ${TASTE_MAP_MAX}`, {
extensions: { code: 'BAD_USER_INPUT' },
});
}

const { sql, params } = tasteWhere(args.filter ?? {});

// Vi viser de vinene flest har vurdert, så kartet fylles med kjente
// viner i stedet for viner med én enkelt vurdering på 5.0.
const winesResult = await pool.query(
`
SELECT
w.id,
w.name,
w.type,
w.abv::float AS abv,
w.body,
w.acidity,
wi.name AS winery,
r.name AS region,
c.name AS country,
w.avg_rating::float AS "avgRating",
w.rating_count AS "ratingCount"
FROM wines w
JOIN wineries wi ON w.winery_id = wi.id
JOIN regions r ON w.region_id = r.id
JOIN countries c ON w.country_code = c.code
WHERE ${sql}
ORDER BY w.rating_count DESC, w.id
LIMIT $${params.length + 1}
`,
[...params, limit],
);

// Totalen telles med de samme filtrene, så "X wines found" viser alle
// vinene som passer, selv om kartet bare tegner de første `limit`.
const countResult = await pool.query(
`
SELECT count(*)::int AS count
FROM wines w
JOIN countries c ON w.country_code = c.code
WHERE ${sql}
`,
params,
);

return {
items: winesResult.rows,
totalCount: countResult.rows[0].count,
};
},

tasteOptions: async () => {
// Fire små spørringer i parallell. Druer begrenses til de 60 vanligste,
// ellers blir nedtrekksmenyen for lang til å være nyttig.
const [types, countries, grapes, years] = await Promise.all([
pool.query(`SELECT DISTINCT type FROM wines ORDER BY type`),
pool.query(`SELECT name FROM countries ORDER BY name`),
pool.query(`
SELECT g.name
FROM grapes g
JOIN wine_grapes wg ON wg.grape_id = g.id
GROUP BY g.name
ORDER BY count(*) DESC
LIMIT 60
`),
pool.query(`
SELECT DISTINCT unnest(vintages) AS year
FROM wines
ORDER BY year DESC
`),
]);

return {
types: types.rows.map((r) => r.type),
countries: countries.rows.map((r) => r.name),
grapes: grapes.rows.map((r) => r.name).sort(),
years: years.rows.map((r) => r.year),
};
},
},
};

Expand Down
2 changes: 1 addition & 1 deletion client/index.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="nb">
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
Expand Down
49 changes: 42 additions & 7 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,52 @@
import { Link, Route, Routes, useParams } from 'react-router';
import SiteHeader from './components/SiteHeader';
import ExploreByTaste from './components/ExploreByTaste';
import WineCatalog from './components/WineCatalog';

/**
* NB: Temporary placeholder. The detail page is not built yet.
* "View wine details" on Explore by taste links here, so the link
* already works. Replace this with the real detail page component.
*/
function WineDetailsPlaceholder() {
const { id } = useParams();
return (
<main>
<h1>Wine details</h1>
<p>The detail page for wine {id} is not built yet.</p>
<Link to="/">← Back to Explore by taste</Link>
</main>
);
}

/**
* Root component for Wine not?
*
* <main> marks the page's main content, so screen reader and keyboard users
* can jump straight to it. The page has one <h1>, and WineList uses <h2>,
* so the heading hierarchy is correct.
* SiteHeader is shown on every page. Routes picks the page from the URL:
* - / Explore by taste (front page)
* - /wines the wine catalog
* - /wines/:id detail page (placeholder for now)
*
* Each page has its own <main> and one <h1>, so the heading hierarchy is
* correct on every page.
*/
export default function App() {
return (
<main>
<h1>Wine not?</h1>
<WineCatalog />
</main>
<>
<SiteHeader />
<Routes>
<Route path="/" element={<ExploreByTaste />} />
<Route
path="/wines"
element={
<main>
<h1>All wines</h1>
<WineCatalog />
</main>
}
/>
<Route path="/wines/:id" element={<WineDetailsPlaceholder />} />
</Routes>
</>
);
}
Binary file added client/src/assets/vineyard.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading