Skip to content

Commit 1717d5f

Browse files
committed
Show the most recent cooked time in the categories list, and order recently-cooked recipes last.
1 parent cfa9e77 commit 1717d5f

File tree

6 files changed

+254
-61
lines changed

6 files changed

+254
-61
lines changed

webserver/e2e/category-list.spec.ts

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { expect } from "@playwright/test";
2+
import type { Recipe } from "@prisma/client";
3+
import { test as baseTest } from "./fixtures.js";
4+
5+
type Fixtures = {
6+
someFilterableRecipes: { recipe1: Recipe; recipe2: Recipe; sandwich: Recipe };
7+
};
8+
const test = baseTest.extend<Fixtures>({
9+
someFilterableRecipes: async (
10+
{ testCategory, testUser, testRecipe },
11+
use
12+
) => {
13+
const dessert = await testCategory.create("dessert");
14+
const supper = await testCategory.create("supper");
15+
const lunch = await testCategory.create("lunch");
16+
const user = await testUser.create({
17+
username: "testuser",
18+
name: "User Name",
19+
});
20+
const cookuser = await testUser.create({
21+
username: "cook",
22+
name: "Cook User",
23+
});
24+
const [recipe1, recipe2, sandwich] = await Promise.all([
25+
testRecipe.create({
26+
author: { connect: { id: user.id } },
27+
name: "Test Recipe 1",
28+
slug: "test-recipe",
29+
ingredients: {
30+
create: [
31+
{ order: 0, name: "flour" },
32+
{ order: 1, name: "sugar" },
33+
{ order: 2, name: "eggs" },
34+
{ order: 3, name: "butter" },
35+
],
36+
},
37+
categories: { connect: { id: dessert.id } },
38+
}),
39+
testRecipe.create({
40+
author: { connect: { id: user.id } },
41+
name: "Recipe 2",
42+
slug: "test-recipe-2",
43+
ingredients: {
44+
create: [{ order: 0, name: "bread" }],
45+
},
46+
categories: { connect: { id: supper.id } },
47+
cookingHistory: {
48+
create: [
49+
{
50+
cookedAtYear: 2023,
51+
cookedAtMonth: 4,
52+
cook: { connect: { id: user.id } },
53+
},
54+
{
55+
cookedAtYear: 2023,
56+
cookedAtMonth: 10,
57+
cook: { connect: { id: cookuser.id } },
58+
},
59+
],
60+
},
61+
}),
62+
testRecipe.create({
63+
author: { connect: { id: user.id } },
64+
name: "Sandwich",
65+
slug: "sandwich",
66+
ingredients: {
67+
create: [
68+
{ order: 0, name: "bread" },
69+
{ order: 1, name: "pickles" },
70+
],
71+
},
72+
categories: { connect: { id: lunch.id } },
73+
}),
74+
]);
75+
await use({ recipe1, recipe2, sandwich });
76+
},
77+
});
78+
79+
test("Category Filters", async ({ page, someFilterableRecipes }) => {
80+
const { recipe2 } = someFilterableRecipes;
81+
82+
await page.goto("/categories");
83+
84+
await expect(page.getByLabel(/Filter/)).toHaveValue("");
85+
86+
await page.getByLabel(/Filter/).fill("s");
87+
await expect(page).toHaveURL("/categories?filter=s");
88+
89+
await expect
90+
.soft(page.locator("#categories").getByRole("listitem").locator("summary"))
91+
.toHaveText([/^Supper$/i, /^Dessert$/i]);
92+
93+
await page
94+
.locator("#categories")
95+
.getByRole("listitem")
96+
.filter({ hasText: /Supper/i })
97+
.click();
98+
await expect
99+
.soft(
100+
page
101+
.locator("#categories")
102+
.getByRole("listitem")
103+
.filter({ hasText: /Supper/i })
104+
.getByRole("listitem")
105+
)
106+
.toHaveText([recipe2.name]);
107+
});
108+
109+
test("Categories sort by history when logged in", async ({
110+
page,
111+
someFilterableRecipes,
112+
testLogin,
113+
testRecipe,
114+
}) => {
115+
const { recipe2: supperApril } = someFilterableRecipes;
116+
const { userId: _ } = testLogin;
117+
118+
const [supperMay, supperNotCooked] = await Promise.all([
119+
testRecipe.create({
120+
author: { connect: { username: "testuser" } },
121+
name: "Cooked in May",
122+
slug: "cooked-in-may",
123+
ingredients: {
124+
create: [{ order: 0, name: "bread" }],
125+
},
126+
categories: { connect: { name: "supper" } },
127+
cookingHistory: {
128+
create: [
129+
{
130+
cookedAtYear: 2023,
131+
cookedAtMonth: 5,
132+
cook: { connect: { username: "testuser" } },
133+
},
134+
],
135+
},
136+
}),
137+
testRecipe.create({
138+
author: { connect: { username: "testuser" } },
139+
name: "Not Cooked",
140+
slug: "not-cooked",
141+
ingredients: {
142+
create: [{ order: 0, name: "bread" }],
143+
},
144+
categories: { connect: { name: "supper" } },
145+
}),
146+
]);
147+
148+
await page.goto("/categories?filter=supper");
149+
150+
await expect
151+
.soft(page.locator("#categories").getByRole("listitem").locator("summary"))
152+
.toHaveText([/^Supper$/i]);
153+
154+
await expect
155+
.soft(
156+
page
157+
.locator("#categories")
158+
.getByRole("listitem")
159+
.filter({ hasText: /Supper/i })
160+
.getByRole("listitem")
161+
)
162+
.toHaveText([
163+
supperNotCooked.name,
164+
`${supperApril.name} last cooked Apr 2023`,
165+
`${supperMay.name} last cooked May 2023`,
166+
]);
167+
});

webserver/e2e/filter.spec.ts

Lines changed: 0 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -156,36 +156,6 @@ test("Ingredient Filters", async ({ page, someFilterableRecipes }) => {
156156
.not.toBeVisible();
157157
});
158158

159-
test("Category Filters", async ({ page, someFilterableRecipes }) => {
160-
const { recipe2 } = someFilterableRecipes;
161-
162-
await page.goto("/categories");
163-
164-
await expect(page.getByLabel(/Filter/)).toHaveValue("");
165-
166-
await page.getByLabel(/Filter/).fill("s");
167-
await expect(page).toHaveURL("/categories?filter=s");
168-
169-
await expect
170-
.soft(page.locator("#categories").getByRole("listitem").locator("summary"))
171-
.toHaveText([/^Supper$/i, /^Dessert$/i]);
172-
173-
await page
174-
.locator("#categories")
175-
.getByRole("listitem")
176-
.filter({ hasText: /Supper/i })
177-
.click();
178-
await expect
179-
.soft(
180-
page
181-
.locator("#categories")
182-
.getByRole("listitem")
183-
.filter({ hasText: /Supper/i })
184-
.getByRole("listitem")
185-
)
186-
.toHaveText([recipe2.name]);
187-
});
188-
189159
test("History Filters", async ({ page, someFilterableRecipes, testLogin }) => {
190160
const { recipe1 } = someFilterableRecipes;
191161
const { userId: _ } = testLogin;
Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
import { getLogin } from "@lib/login-cookie";
22
import { prisma } from "@lib/prisma";
3-
import type { CookingHistory, Prisma } from "@prisma/client";
3+
import type { Prisma } from "@prisma/client";
44
import { defineAction, type ActionAPIContext } from "astro:actions";
55
import { z } from "astro:schema";
66

7-
export type RecipesInCategoryResponse = {
7+
export type RecipeInCategoryResponse = {
88
name: string;
99
slug: string;
1010
author: {
1111
username: string;
1212
};
13-
cookingHistory: CookingHistory[] | undefined;
14-
}[];
13+
lastCooked: { year: number; month: number; day: number } | undefined;
14+
};
1515

1616
const input = z.object({
1717
categoryIds: z.array(z.number().int()),
@@ -22,7 +22,7 @@ type Input = z.infer<typeof input>;
2222
async function handler(
2323
{ categoryIds, author }: Input,
2424
{ cookies }: ActionAPIContext
25-
): Promise<RecipesInCategoryResponse> {
25+
): Promise<RecipeInCategoryResponse[]> {
2626
const activeUser = await getLogin(cookies);
2727

2828
const where: Prisma.RecipeWhereInput = {
@@ -36,17 +36,45 @@ async function handler(
3636
where.author = { username: author };
3737
}
3838

39-
return await prisma.recipe.findMany({
40-
where,
41-
select: {
42-
name: true,
43-
slug: true,
44-
author: { select: { username: true } },
45-
cookingHistory:
46-
activeUser === null ? undefined : { where: { cookId: activeUser.id } },
39+
return (
40+
await prisma.recipe.findMany({
41+
where,
42+
select: {
43+
name: true,
44+
slug: true,
45+
author: { select: { username: true } },
46+
cookingHistory:
47+
activeUser === null
48+
? undefined
49+
: {
50+
where: { cookId: activeUser.id },
51+
// Return just the latest cooking time.
52+
orderBy: [
53+
{
54+
cookedAtYear: "desc",
55+
},
56+
{
57+
cookedAtMonth: "desc",
58+
},
59+
{
60+
cookedAtDay: { sort: "desc", nulls: "last" },
61+
},
62+
],
63+
take: 1,
64+
},
65+
},
66+
orderBy: { name: "asc" },
67+
})
68+
).map(({ name, slug, author, cookingHistory }) => ({
69+
name,
70+
slug,
71+
author,
72+
lastCooked: cookingHistory?.[0] && {
73+
year: cookingHistory[0].cookedAtYear,
74+
month: cookingHistory[0].cookedAtMonth,
75+
day: cookingHistory[0].cookedAtDay ?? 1,
4776
},
48-
orderBy: { name: "asc" },
49-
});
77+
}));
5078
}
5179

5280
export const recipesInCategory = defineAction({ input, handler });

webserver/src/components/CategoryList.tsx

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { escapeRegExp, filterListWithInitialMatchesFirst } from "@lib/util";
1+
import { Temporal } from "@js-temporal/polyfill";
2+
import {
3+
escapeRegExp,
4+
filterListWithInitialMatchesFirst,
5+
formatMonth,
6+
} from "@lib/util";
27
import type { Category } from "@prisma/client";
38
import { actions } from "astro:actions";
49
import {
@@ -13,7 +18,8 @@ import {
1318
type Setter,
1419
Suspense,
1520
} from "solid-js";
16-
import { OneRecipe, type RecipeTitleWithLinkFields } from "./OneRecipe";
21+
import type { RecipeInCategoryResponse } from "src/actions/recipes-in-category";
22+
import { OneRecipe } from "./OneRecipe";
1723
import { QueryDrivenTextField } from "./QueryDrivenTextField";
1824

1925
export const CategoryList: Component<{
@@ -32,16 +38,32 @@ export const CategoryList: Component<{
3238
type PendingRecipeList = {
3339
needRecipes: Accessor<boolean>;
3440
setNeedRecipes: Setter<boolean>;
35-
recipes: Resource<RecipeTitleWithLinkFields[]>;
41+
recipes: Resource<RecipeInCategoryResponse[]>;
3642
};
3743

3844
const recipesByCategory = createMemo(() => {
3945
return new Map<number, PendingRecipeList>(
4046
props.categories.map((category) => {
4147
const [needRecipes, setNeedRecipes] = createSignal(false);
42-
const [recipes] = createResource(needRecipes, () =>
43-
actions.recipesInCategory.orThrow({ categoryIds: [category.id] })
44-
);
48+
const [recipes] = createResource(needRecipes, async () => {
49+
const recipes = await actions.recipesInCategory.orThrow({
50+
categoryIds: [category.id],
51+
});
52+
recipes.sort(
53+
(a: RecipeInCategoryResponse, b: RecipeInCategoryResponse) => {
54+
const aHasCookingHistory = a.lastCooked !== undefined;
55+
const bHasCookingHistory = b.lastCooked !== undefined;
56+
if (aHasCookingHistory !== bHasCookingHistory) {
57+
if (aHasCookingHistory) return 1;
58+
return -1;
59+
}
60+
if (a.lastCooked && b.lastCooked)
61+
return Temporal.PlainDate.compare(a.lastCooked, b.lastCooked);
62+
return 0;
63+
}
64+
);
65+
return recipes;
66+
});
4567
return [category.id, { needRecipes, setNeedRecipes, recipes }];
4668
})
4769
);
@@ -94,6 +116,10 @@ export const CategoryList: Component<{
94116
{(recipe) => (
95117
<li>
96118
<OneRecipe recipe={recipe} />
119+
{recipe.lastCooked &&
120+
` last cooked ${formatMonth(
121+
Temporal.PlainYearMonth.from(recipe.lastCooked)
122+
)}`}
97123
</li>
98124
)}
99125
</For>

webserver/src/components/HistoryList.tsx

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Temporal } from "@js-temporal/polyfill";
2-
import { escapeRegExp } from "@lib/util";
2+
import { escapeRegExp, formatMonth } from "@lib/util";
33
import { actions } from "astro:actions";
44
import {
55
For,
@@ -16,15 +16,6 @@ import {
1616
import { OneRecipe, type RecipeTitleWithLinkFields } from "./OneRecipe";
1717
import { QueryDrivenTextField } from "./QueryDrivenTextField";
1818

19-
function formatMonth(m: Temporal.PlainYearMonth) {
20-
// Avoid using the PlainYearMonth's calendar, which is probably iso8601, because Node 22 formats
21-
// it differently from the default Gregorian calendar.
22-
return m.toPlainDate({ day: 1 }).toLocaleString(undefined, {
23-
year: "numeric",
24-
month: "short",
25-
});
26-
}
27-
2819
export const HistoryList: Component<{
2920
/** In ISO YYYY-MM format. */
3021
months: string[];

0 commit comments

Comments
 (0)