Merge pull request #12 from kyuno053/feat/planning-page-design
Page Planning — grille hebdomadaire et navigation par semaine
This commit is contained in:
commit
1a0d4e3962
27 changed files with 1230 additions and 297 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -144,3 +144,6 @@ vite.config.ts.timestamp-*
|
|||
|
||||
# Project docs not meant to be committed
|
||||
Projet batch cooking.pdf
|
||||
|
||||
# Throwaway HTML mockups used to review a design before implementing it
|
||||
tmp-mockups/
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { DateTime } from "@batch-cooking/date-tools";
|
||||
import { Given, Then, When } from "@cucumber/cucumber";
|
||||
import { prisma } from "../../src/db/prisma.js";
|
||||
import type { CustomWorld } from "../support/world.js";
|
||||
|
||||
/** `GET /planning` takes `?date=` explicitly — this scenario wording ("the current planning") maps to "today". */
|
||||
When("I request the current planning", async function (this: CustomWorld) {
|
||||
this.response = await this.agent.get("/planning/current");
|
||||
this.response = await this.agent.get("/planning").query({ date: DateTime.utc().toISODate() });
|
||||
});
|
||||
|
||||
Then("the current planning response should be empty", function (this: CustomWorld) {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
"seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@batch-cooking/date-tools": "workspace:*",
|
||||
"@batch-cooking/error-tools": "workspace:*",
|
||||
"@batch-cooking/express-tools": "workspace:*",
|
||||
"@batch-cooking/shared": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -1,21 +1,36 @@
|
|||
import { parseDateOnly } from "@batch-cooking/date-tools";
|
||||
import { HttpError } from "@batch-cooking/error-tools";
|
||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||
import { ErrorCode, getPlanningByDateSchema } from "@batch-cooking/shared";
|
||||
import { Router } from "express";
|
||||
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
||||
import { getCurrentPlanning } from "./planning.service.js";
|
||||
import { getPlanningForDate } from "./planning.service.js";
|
||||
|
||||
/** Router mounted at `/planning` in app.ts. */
|
||||
export const planningRouter = Router();
|
||||
|
||||
/**
|
||||
* Returns the authenticated user's household's planning for today, or
|
||||
* `null` if none exists yet — a valid, common response, not an error (see
|
||||
* {@link getCurrentPlanning}).
|
||||
* Returns the authenticated user's household's planning covering `?date=`
|
||||
* (`YYYY-MM-DD`), or `null` if none exists yet — a valid, common response,
|
||||
* not an error (see {@link getPlanningForDate}). Used both for "today"
|
||||
* (the planning page's initial load) and for any other week the planning
|
||||
* page's week navigator/calendar picks.
|
||||
*/
|
||||
planningRouter.get(
|
||||
"/current",
|
||||
"/",
|
||||
requireAuth,
|
||||
wrapAsyncHandler<unknown, AuthLocals>(async (_req, res) => {
|
||||
const planning = await getCurrentPlanning(res.locals.userProfile.houseId);
|
||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
||||
const input = getPlanningByDateSchema.parse(req.query);
|
||||
const date = parseDateOnly(input.date);
|
||||
if (date === null) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
ErrorCode.VALIDATION_ERROR,
|
||||
`Not a real calendar date: ${input.date}`,
|
||||
);
|
||||
}
|
||||
|
||||
const planning = await getPlanningForDate(res.locals.userProfile.houseId, date);
|
||||
res.status(200).json(planning);
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,35 +1,40 @@
|
|||
import { type DateTime, toDateOnly } from "@batch-cooking/date-tools";
|
||||
import type { PlanningView } from "@batch-cooking/shared";
|
||||
import { prisma } from "../../db/prisma.js";
|
||||
|
||||
/**
|
||||
* Finds the household's planning that covers today's date and shapes it
|
||||
* into a {@link PlanningView} (recipes resolved to `{id, name}`).
|
||||
* Finds the household's planning that covers `date` and shapes it into a
|
||||
* {@link PlanningView} (recipes resolved to `{id, name}`). `date` is
|
||||
* whatever the caller wants "now" to mean — the current `/planning` route
|
||||
* passes a `date-tools`-parsed `?date=` query param, letting a caller look
|
||||
* up any week's planning, not just the one covering today.
|
||||
*
|
||||
* Returns `null` for two distinct, both entirely normal states — a `house_id`
|
||||
* of `null` (a profile always gets a house at signup today, but the column
|
||||
* is nullable) and "no planning row covers today" (the expected case until
|
||||
* planning creation is built) — neither is an error, so both collapse to
|
||||
* the same "nothing to show yet" result rather than throwing.
|
||||
* Returns `null` for two distinct, both entirely normal states — a
|
||||
* `houseId` of `null` (the profile has no household yet — households are no
|
||||
* longer created automatically at signup, see `auth.service.ts`) and "no
|
||||
* planning row covers this date" (the expected case until planning
|
||||
* creation is built) — neither is an error, so both collapse to the same
|
||||
* "nothing to show yet" result rather than throwing.
|
||||
*/
|
||||
export async function getCurrentPlanning(houseId: number | null): Promise<PlanningView | null> {
|
||||
export async function getPlanningForDate(
|
||||
houseId: number | null,
|
||||
date: DateTime,
|
||||
): Promise<PlanningView | null> {
|
||||
if (houseId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// `startDate`/`finishDate` are `@db.Date` columns (no time-of-day
|
||||
// component) — compare against today's date at UTC midnight so the
|
||||
// comparison lines up with how Postgres stores/returns them, regardless
|
||||
// of the server's local timezone.
|
||||
const today = new Date();
|
||||
const todayDateOnly = new Date(
|
||||
Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()),
|
||||
);
|
||||
// component) — comparing against a UTC-midnight JS `Date` lines up with
|
||||
// how Postgres stores/returns them, regardless of the server's local
|
||||
// timezone.
|
||||
const dateOnly = toDateOnly(date).toJSDate();
|
||||
|
||||
const planning = await prisma.planning.findFirst({
|
||||
where: {
|
||||
houseId,
|
||||
startDate: { lte: todayDateOnly },
|
||||
finishDate: { gte: todayDateOnly },
|
||||
startDate: { lte: dateOnly },
|
||||
finishDate: { gte: dateOnly },
|
||||
},
|
||||
// A household should never have two plannings covering the same day,
|
||||
// but nothing in the schema enforces that yet — pick the most recently
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { DateTime } from "@batch-cooking/date-tools";
|
||||
import { ErrorCode, type SignupInput } from "@batch-cooking/shared";
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { expect } from "chai";
|
||||
|
|
@ -18,6 +19,18 @@ function buildSignupPayload(): SignupInput {
|
|||
};
|
||||
}
|
||||
|
||||
/** Today, as the `YYYY-MM-DD` string `GET /planning`'s `?date=` expects. */
|
||||
function today(): string {
|
||||
return isoDate(DateTime.utc());
|
||||
}
|
||||
|
||||
/** `toISODate()` only returns `null` for an invalid `DateTime` — never the case for the always-valid values built in this file. */
|
||||
function isoDate(date: DateTime): string {
|
||||
const iso = date.toISODate();
|
||||
if (iso === null) throw new Error("Unexpectedly invalid DateTime in a test helper");
|
||||
return iso;
|
||||
}
|
||||
|
||||
describe("Planning", () => {
|
||||
const app = createApp();
|
||||
|
||||
|
|
@ -29,63 +42,93 @@ describe("Planning", () => {
|
|||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe("GET /planning/current", () => {
|
||||
describe("GET /planning", () => {
|
||||
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
|
||||
const res = await request(app).get("/planning/current");
|
||||
const res = await request(app).get("/planning").query({ date: today() });
|
||||
|
||||
expect(res.status).to.equal(401);
|
||||
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
||||
});
|
||||
|
||||
it("returns null when the household has no planning covering today", async () => {
|
||||
it("rejects a missing date with 400 VALIDATION_ERROR", async () => {
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||
|
||||
const res = await agent.get("/planning/current");
|
||||
const res = await agent.get("/planning");
|
||||
|
||||
expect(res.status).to.equal(400);
|
||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||
});
|
||||
|
||||
it("rejects a malformed date with 400 VALIDATION_ERROR", async () => {
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||
|
||||
const res = await agent.get("/planning").query({ date: "not-a-date" });
|
||||
|
||||
expect(res.status).to.equal(400);
|
||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||
});
|
||||
|
||||
it("rejects a date shaped right but calendarially impossible with 400 VALIDATION_ERROR", async () => {
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||
|
||||
const res = await agent.get("/planning").query({ date: "2026-02-30" });
|
||||
|
||||
expect(res.status).to.equal(400);
|
||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||
});
|
||||
|
||||
it("returns null when the household has no planning covering that date", async () => {
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||
|
||||
const res = await agent.get("/planning").query({ date: today() });
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body).to.equal(null);
|
||||
});
|
||||
|
||||
it("returns the household's planning covering today, with recipes resolved", async () => {
|
||||
it("returns the household's planning covering that date, with recipes resolved", async () => {
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
|
||||
const houseId: number = houseRes.body.id;
|
||||
|
||||
const recipe = await prisma.recipe.create({ data: { name: "Ratatouille" } });
|
||||
const today = new Date();
|
||||
const now = new Date();
|
||||
const planning = await prisma.planning.create({
|
||||
data: {
|
||||
houseId,
|
||||
startDate: new Date(
|
||||
Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() - 2),
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - 2),
|
||||
),
|
||||
finishDate: new Date(
|
||||
Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() + 2),
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 2),
|
||||
),
|
||||
},
|
||||
});
|
||||
await prisma.planningItem.create({
|
||||
data: { planningId: planning.id, weekDay: "monday", meal: "dinner", recipeId: recipe.id },
|
||||
data: { planningId: planning.id, weekDay: "lundi", meal: "diner", recipeId: recipe.id },
|
||||
});
|
||||
|
||||
const res = await agent.get("/planning/current");
|
||||
const res = await agent.get("/planning").query({ date: today() });
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body.id).to.equal(planning.id);
|
||||
expect(res.body.items).to.have.length(1);
|
||||
expect(res.body.items[0]).to.include({ weekDay: "monday", meal: "dinner" });
|
||||
expect(res.body.items[0]).to.include({ weekDay: "lundi", meal: "diner" });
|
||||
expect(res.body.items[0].recipe).to.include({ id: recipe.id, name: "Ratatouille" });
|
||||
});
|
||||
|
||||
it("returns null when the household's planning does not cover today", async () => {
|
||||
it("returns null when the household's planning does not cover that date", async () => {
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
|
||||
const houseId: number = houseRes.body.id;
|
||||
|
||||
// A planning entirely in the past — shouldn't be picked up as "current".
|
||||
// A planning entirely in the past — shouldn't be picked up for today.
|
||||
await prisma.planning.create({
|
||||
data: {
|
||||
houseId,
|
||||
|
|
@ -94,10 +137,38 @@ describe("Planning", () => {
|
|||
},
|
||||
});
|
||||
|
||||
const res = await agent.get("/planning/current");
|
||||
const res = await agent.get("/planning").query({ date: today() });
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body).to.equal(null);
|
||||
});
|
||||
|
||||
it("returns a different week's planning when asked for a date outside the current one", async () => {
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
|
||||
const houseId: number = houseRes.body.id;
|
||||
|
||||
const recipe = await prisma.recipe.create({ data: { name: "Curry de lentilles" } });
|
||||
const nextWeek = DateTime.utc().plus({ weeks: 1 });
|
||||
const planning = await prisma.planning.create({
|
||||
data: {
|
||||
houseId,
|
||||
startDate: nextWeek.startOf("week").toJSDate(),
|
||||
finishDate: nextWeek.endOf("week").startOf("day").toJSDate(),
|
||||
},
|
||||
});
|
||||
await prisma.planningItem.create({
|
||||
data: { planningId: planning.id, weekDay: "mardi", meal: "dejeuner", recipeId: recipe.id },
|
||||
});
|
||||
|
||||
const res = await agent.get("/planning").query({ date: isoDate(nextWeek) });
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body.id).to.equal(planning.id);
|
||||
|
||||
const thisWeekRes = await agent.get("/planning").query({ date: today() });
|
||||
expect(thisWeekRes.body).to.equal(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,104 +0,0 @@
|
|||
// Mocks the API via cy.intercept — see auth.cy.ts for the rationale (no
|
||||
// live backend in this CI job; apps/api's own Mocha/Cucumber suites cover
|
||||
// real API behavior against a real database).
|
||||
|
||||
const authenticatedProfile = {
|
||||
id: 1,
|
||||
firstName: "Alice",
|
||||
lastName: "Martin",
|
||||
email: "alice@example.com",
|
||||
tokenVersion: 0,
|
||||
houseId: 1,
|
||||
dietId: null,
|
||||
};
|
||||
|
||||
describe("Sidebar navigation", () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
|
||||
cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null });
|
||||
cy.visit("/");
|
||||
});
|
||||
|
||||
// The Foyer/Compte/Préférences links — behind the sidebar's "Paramètres"
|
||||
// toggle, not the main nav tested here — are covered by sidebar.cy.ts.
|
||||
it("highlights the current section and navigates between stub pages", () => {
|
||||
cy.contains("nav a", "Planning").should("have.class", "active");
|
||||
|
||||
cy.contains("nav a", "Recettes").click();
|
||||
cy.url().should("include", "/recettes");
|
||||
cy.contains("h1", "Recettes").should("be.visible");
|
||||
cy.contains("nav a", "Recettes").should("have.class", "active");
|
||||
cy.contains("nav a", "Planning").should("not.have.class", "active");
|
||||
|
||||
cy.contains("nav a", "Liste de courses").click();
|
||||
cy.url().should("include", "/liste-de-courses");
|
||||
cy.contains("h1", "Liste de courses").should("be.visible");
|
||||
|
||||
cy.contains("nav a", "Planning").click();
|
||||
cy.url().should("eq", `${Cypress.config().baseUrl}/`);
|
||||
cy.contains("h1", "Planning de la semaine").should("be.visible");
|
||||
});
|
||||
|
||||
it("shows the signed-in user's name and lets them log out from the account menu", () => {
|
||||
cy.intercept("POST", "**/auth/logout", { statusCode: 204 }).as("logout");
|
||||
|
||||
cy.contains("button", "Bonjour Alice").should("be.visible").click();
|
||||
cy.contains("button", "Se déconnecter").click();
|
||||
|
||||
cy.wait("@logout");
|
||||
cy.url().should("include", "/login");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Home planning view", () => {
|
||||
it("shows an empty state when the household has no current planning", () => {
|
||||
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
|
||||
cy.intercept("GET", "**/planning/current", { statusCode: 200, body: null });
|
||||
|
||||
cy.visit("/");
|
||||
|
||||
cy.contains("h1", "Planning de la semaine").should("be.visible");
|
||||
cy.contains("Aucun planning pour cette semaine.").should("be.visible");
|
||||
cy.get("table").should("not.exist");
|
||||
});
|
||||
|
||||
it("renders the current planning's meals when there is one", () => {
|
||||
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
|
||||
cy.intercept("GET", "**/planning/current", {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
id: 1,
|
||||
startDate: "2026-08-10T00:00:00.000Z",
|
||||
finishDate: "2026-08-16T00:00:00.000Z",
|
||||
items: [
|
||||
{ id: 1, weekDay: "lundi", meal: "Dîner", recipe: { id: 1, name: "Ratatouille" } },
|
||||
{
|
||||
id: 2,
|
||||
weekDay: "mardi",
|
||||
meal: "Déjeuner",
|
||||
recipe: { id: 2, name: "Curry de lentilles" },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
cy.visit("/");
|
||||
|
||||
cy.contains("Aucun planning pour cette semaine.").should("not.exist");
|
||||
cy.get("table.planning-table tbody tr").should("have.length", 2);
|
||||
cy.contains("td", "Ratatouille").should("be.visible");
|
||||
cy.contains("td", "Curry de lentilles").should("be.visible");
|
||||
});
|
||||
|
||||
it("shows an error state when the planning request fails", () => {
|
||||
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
|
||||
cy.intercept("GET", "**/planning/current", {
|
||||
statusCode: 500,
|
||||
body: { code: 5000, message: "boom" },
|
||||
});
|
||||
|
||||
cy.visit("/");
|
||||
|
||||
cy.contains("Impossible de charger le planning, réessayez plus tard").should("be.visible");
|
||||
});
|
||||
});
|
||||
163
apps/web/cypress/e2e/planning-page.cy.ts
Normal file
163
apps/web/cypress/e2e/planning-page.cy.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
// Mocks the API via cy.intercept — see auth.cy.ts for the rationale (no
|
||||
// live backend in this CI job; apps/api's own Mocha/Cucumber suites cover
|
||||
// real API behavior against a real database).
|
||||
|
||||
const authenticatedProfile = {
|
||||
id: 1,
|
||||
firstName: "Alice",
|
||||
lastName: "Martin",
|
||||
email: "alice@example.com",
|
||||
tokenVersion: 0,
|
||||
houseId: 1,
|
||||
dietId: null,
|
||||
};
|
||||
|
||||
// 2026-08-17 is a Monday — frozen via `cy.clock` so "today"/"this week"
|
||||
// assertions are deterministic instead of depending on the day the suite
|
||||
// happens to run.
|
||||
const TODAY = new Date("2026-08-17T09:00:00Z");
|
||||
|
||||
function freezeToday() {
|
||||
cy.clock(TODAY, ["Date"]);
|
||||
}
|
||||
|
||||
describe("Sidebar navigation", () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
|
||||
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null });
|
||||
cy.visit("/");
|
||||
});
|
||||
|
||||
// The Foyer/Compte/Préférences links — behind the sidebar's "Paramètres"
|
||||
// toggle, not the main nav tested here — are covered by sidebar.cy.ts.
|
||||
it("highlights the current section and navigates between stub pages", () => {
|
||||
cy.contains("nav a", "Planning").should("have.class", "active");
|
||||
|
||||
cy.contains("nav a", "Recettes").click();
|
||||
cy.url().should("include", "/recettes");
|
||||
cy.contains("h1", "Recettes").should("be.visible");
|
||||
cy.contains("nav a", "Recettes").should("have.class", "active");
|
||||
cy.contains("nav a", "Planning").should("not.have.class", "active");
|
||||
|
||||
cy.contains("nav a", "Liste de courses").click();
|
||||
cy.url().should("include", "/liste-de-courses");
|
||||
cy.contains("h1", "Liste de courses").should("be.visible");
|
||||
|
||||
cy.contains("nav a", "Planning").click();
|
||||
cy.url().should("eq", `${Cypress.config().baseUrl}/`);
|
||||
cy.contains("h1", "Planning de la semaine").should("be.visible");
|
||||
});
|
||||
|
||||
it("shows the signed-in user's name and lets them log out from the account menu", () => {
|
||||
cy.intercept("POST", "**/auth/logout", { statusCode: 204 }).as("logout");
|
||||
|
||||
cy.contains("button", "Bonjour Alice").should("be.visible").click();
|
||||
cy.contains("button", "Se déconnecter").click();
|
||||
|
||||
cy.wait("@logout");
|
||||
cy.url().should("include", "/login");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Planning grid", () => {
|
||||
beforeEach(() => {
|
||||
// Desktop-only design (see the plan/PR description) — wider than
|
||||
// Cypress's default 1000×660 so all 7 day columns fit without the grid's
|
||||
// horizontal scroll hiding the later ones from visibility assertions.
|
||||
cy.viewport(1600, 900);
|
||||
freezeToday();
|
||||
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
|
||||
});
|
||||
|
||||
it("shows an empty grid (every slot just offering '+') when the household has no planning yet", () => {
|
||||
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }).as("getPlanning");
|
||||
|
||||
cy.visit("/");
|
||||
cy.wait("@getPlanning").its("request.url").should("include", "date=2026-08-17");
|
||||
|
||||
cy.contains("h1", "Planning de la semaine").should("be.visible");
|
||||
// 5 meal rows × 7 days = 35 empty slots, each just a "+".
|
||||
cy.get(".add-recipe-btn").should("have.length", 35);
|
||||
cy.get(".recipe-chip").should("not.exist");
|
||||
});
|
||||
|
||||
it("renders each recipe in its (day, meal) cell, and highlights today's column", () => {
|
||||
cy.intercept("GET", /\/planning\?/, {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
id: 1,
|
||||
startDate: "2026-08-17T00:00:00.000Z",
|
||||
finishDate: "2026-08-23T00:00:00.000Z",
|
||||
items: [
|
||||
{ id: 1, weekDay: "mardi", meal: "diner", recipe: { id: 1, name: "Ratatouille" } },
|
||||
{
|
||||
id: 2,
|
||||
weekDay: "mercredi",
|
||||
meal: "dejeuner",
|
||||
recipe: { id: 2, name: "Curry de lentilles" },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
cy.visit("/");
|
||||
|
||||
cy.contains("th", "Lundi").should("be.visible");
|
||||
cy.contains("th", "Dimanche").should("be.visible");
|
||||
cy.contains(".recipe-chip", "Ratatouille").should("be.visible");
|
||||
cy.contains(".recipe-chip", "Curry de lentilles").should("be.visible");
|
||||
|
||||
// Today (17 août, Lundi) is marked — its column header carries `.today`.
|
||||
cy.contains("th.today .day-date", "17").should("be.visible");
|
||||
});
|
||||
|
||||
it("shows a loading state, then an error state when the request fails", () => {
|
||||
cy.intercept("GET", /\/planning\?/, {
|
||||
statusCode: 500,
|
||||
body: { code: 5000, message: "boom" },
|
||||
});
|
||||
|
||||
cy.visit("/");
|
||||
|
||||
cy.contains("Impossible de charger le planning, réessayez plus tard").should("be.visible");
|
||||
});
|
||||
|
||||
// Assertions below check the rendered week label/badge, not the intercepted
|
||||
// request count — React StrictMode (see main.tsx) double-invokes effects in
|
||||
// dev, so the `GET /planning` mount effect can fire twice per navigation;
|
||||
// counting exact `cy.wait` calls against that would be flaky, but the
|
||||
// rendered result is the same either way.
|
||||
it("navigates to the next/previous week, re-fetching each time", () => {
|
||||
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }).as("getPlanning");
|
||||
|
||||
cy.visit("/");
|
||||
cy.wait("@getPlanning").its("request.url").should("include", "date=2026-08-17");
|
||||
cy.contains("Semaine du 17 au 23 août 2026").should("be.visible");
|
||||
cy.contains("Cette semaine").should("be.visible");
|
||||
|
||||
cy.get(".week-nav__arrow").last().click();
|
||||
cy.contains("Semaine du 24 au 30 août 2026").should("be.visible");
|
||||
cy.contains("Cette semaine").should("not.exist");
|
||||
|
||||
cy.get(".week-nav__arrow").first().click();
|
||||
cy.contains("Semaine du 17 au 23 août 2026").should("be.visible");
|
||||
cy.get(".week-nav__arrow").first().click();
|
||||
cy.contains("Semaine du 10 au 16 août 2026").should("be.visible");
|
||||
});
|
||||
|
||||
it("jumps to an arbitrary week by picking a day in the calendar popover", () => {
|
||||
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }).as("getPlanning");
|
||||
|
||||
cy.visit("/");
|
||||
cy.wait("@getPlanning");
|
||||
|
||||
cy.contains("button", "Semaine du").click();
|
||||
cy.get(".calendar-popover").should("be.visible");
|
||||
// Picking the 25th (still August, unambiguous in the visible grid)
|
||||
// should jump to the week of the 24th–30th.
|
||||
cy.get(".calendar-grid__day").contains(/^25$/).click();
|
||||
|
||||
cy.contains("Semaine du 24 au 30 août 2026").should("be.visible");
|
||||
cy.get(".calendar-popover").should("not.exist");
|
||||
});
|
||||
});
|
||||
|
|
@ -13,6 +13,7 @@
|
|||
"e2e": "start-server-and-test dev http://localhost:5173 cy:run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@batch-cooking/date-tools": "workspace:*",
|
||||
"@batch-cooking/shared": "workspace:*",
|
||||
"i18next": "^26.3.6",
|
||||
"react": "^18.3.1",
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import { Navigate, Route, Routes } from "react-router-dom";
|
|||
import { RedirectIfAuthenticated } from "./features/auth/RedirectIfAuthenticated";
|
||||
import { RequireAuth } from "./features/auth/RequireAuth";
|
||||
import { AppLayout } from "./layouts/AppLayout";
|
||||
import { HomePage } from "./pages/HomePage";
|
||||
import { LoginPage } from "./pages/LoginPage";
|
||||
import { PlanningPage } from "./pages/PlanningPage";
|
||||
import { RecipesPage } from "./pages/RecipesPage";
|
||||
import { ShoppingListPage } from "./pages/ShoppingListPage";
|
||||
import { SignupPage } from "./pages/SignupPage";
|
||||
|
|
@ -45,7 +45,7 @@ export function App() {
|
|||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/" element={<PlanningPage />} />
|
||||
<Route path="/recettes" element={<RecipesPage />} />
|
||||
<Route path="/liste-de-courses" element={<ShoppingListPage />} />
|
||||
<Route path="/parametres/compte" element={<AccountSettingsPage />} />
|
||||
|
|
|
|||
|
|
@ -103,9 +103,13 @@ export class ApiClient {
|
|||
return this.request("/auth/me", { method: "DELETE", body: JSON.stringify({ password }) });
|
||||
}
|
||||
|
||||
/** Fetches the current user's household's planning for today, or `null` if there isn't one yet. */
|
||||
public getCurrentPlanning(): Promise<PlanningView | null> {
|
||||
return this.request("/planning/current");
|
||||
/**
|
||||
* Fetches the current user's household's planning covering `date`
|
||||
* (`YYYY-MM-DD`, e.g. from `date-tools`'s `formatDateOnly`), or `null` if
|
||||
* there isn't one for that week yet.
|
||||
*/
|
||||
public getPlanningForWeek(date: string): Promise<PlanningView | null> {
|
||||
return this.request(`/planning?date=${date}`);
|
||||
}
|
||||
|
||||
/** Reference list of dietary regimes to pick from (signup wizard, `/foyer`). Public — no session required. */
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
{
|
||||
"common": {
|
||||
"saving": "Enregistrement…",
|
||||
"saved": "Enregistré ✓"
|
||||
"saved": "Enregistré ✓",
|
||||
"loadError": "Impossible de charger le planning, réessayez plus tard"
|
||||
},
|
||||
"errors": {
|
||||
"VALIDATION_ERROR": "Erreur de validation",
|
||||
|
|
@ -77,15 +78,38 @@
|
|||
"greeting": "Bonjour {{firstName}} 👋",
|
||||
"logout": "Se déconnecter"
|
||||
},
|
||||
"home": {
|
||||
"planning": {
|
||||
"title": "Planning de la semaine",
|
||||
"loading": "Chargement du planning…",
|
||||
"error": "Impossible de charger le planning, réessayez plus tard",
|
||||
"empty": "Aucun planning pour cette semaine.",
|
||||
"table": {
|
||||
"day": "Jour",
|
||||
"meal": "Repas",
|
||||
"recipe": "Recette"
|
||||
"weekNav": {
|
||||
"thisWeek": "Cette semaine",
|
||||
"prevWeek": "Semaine précédente",
|
||||
"nextWeek": "Semaine suivante",
|
||||
"label": "Semaine du {{range}}"
|
||||
},
|
||||
"calendar": {
|
||||
"prevMonth": "Mois précédent",
|
||||
"nextMonth": "Mois suivant"
|
||||
},
|
||||
"days": {
|
||||
"lundi": "Lundi",
|
||||
"mardi": "Mardi",
|
||||
"mercredi": "Mercredi",
|
||||
"jeudi": "Jeudi",
|
||||
"vendredi": "Vendredi",
|
||||
"samedi": "Samedi",
|
||||
"dimanche": "Dimanche"
|
||||
},
|
||||
"meals": {
|
||||
"petit-dejeuner": "Petit-déjeuner",
|
||||
"collation": "Collation",
|
||||
"dejeuner": "Déjeuner",
|
||||
"gouter": "Goûter",
|
||||
"diner": "Dîner"
|
||||
},
|
||||
"grid": {
|
||||
"addRecipeSoon": "Recherche de recettes à venir",
|
||||
"removeRecipe": "Retirer cette recette"
|
||||
}
|
||||
},
|
||||
"recipes": {
|
||||
|
|
|
|||
|
|
@ -1,56 +0,0 @@
|
|||
// =============================================================================
|
||||
// Styles specific to HomePage — colocated next to HomePage.tsx since nothing
|
||||
// else uses these classes.
|
||||
// =============================================================================
|
||||
|
||||
// No `@use` of the theme partial needed here: every design token below is a
|
||||
// CSS custom property (--color-*, --space-*...) declared once on :root in
|
||||
// styles/global.scss and available globally at runtime — not a Sass-level
|
||||
// variable/mixin that would require an explicit compile-time import.
|
||||
|
||||
// No outer centering wrapper here (unlike the old version of this file):
|
||||
// AppLayout's `.app-content` already owns the page background/padding —
|
||||
// this is just the page's own content.
|
||||
.home-page {
|
||||
&__status {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-md);
|
||||
}
|
||||
|
||||
&__status--error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
}
|
||||
|
||||
// The current planning, one row per meal slot. Raised on its own surface,
|
||||
// same card treatment used elsewhere in the app, so it reads as a distinct
|
||||
// piece of content rather than bare text on the page background.
|
||||
.planning-table {
|
||||
width: 100%;
|
||||
max-width: 40rem;
|
||||
margin-top: var(--space-md);
|
||||
border-collapse: collapse;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-sm);
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
th {
|
||||
background: var(--color-surface-alt);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
import type { PlanningView } from "@batch-cooking/shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { apiClient } from "../api/client";
|
||||
import "./HomePage.scss";
|
||||
|
||||
/** Load state for the `GET /planning/current` call — a discriminated union so a stale/impossible combination (e.g. "loading" with data) can't be represented. */
|
||||
type PlanningState =
|
||||
| { status: "loading" }
|
||||
| { status: "loaded"; planning: PlanningView | null }
|
||||
| { status: "error" };
|
||||
|
||||
/**
|
||||
* Landing page for an authenticated visitor — the household's current
|
||||
* planning. Behind {@link RequireAuth} (via `AppLayout`), so this only
|
||||
* renders once a session is confirmed; the planning itself still has to be
|
||||
* fetched separately, hence the loading/error/empty/loaded states below.
|
||||
* `null` from the API is a normal, common state (no planning created yet),
|
||||
* not an error — see `apps/api`'s `planning.service.ts`.
|
||||
*/
|
||||
export function HomePage() {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<PlanningState>({ status: "loading" });
|
||||
|
||||
useEffect(() => {
|
||||
// Guards against setting state after unmount (e.g. the user navigates
|
||||
// away before the request resolves) — no cleanup-worthy resource here,
|
||||
// just avoids a "set state on unmounted component" warning.
|
||||
let cancelled = false;
|
||||
|
||||
apiClient
|
||||
.getCurrentPlanning()
|
||||
.then((planning) => {
|
||||
if (!cancelled) setState({ status: "loaded", planning });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setState({ status: "error" });
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="home-page">
|
||||
<h1>{t("home.title")}</h1>
|
||||
|
||||
{state.status === "loading" && <p className="home-page__status">{t("home.loading")}</p>}
|
||||
|
||||
{state.status === "error" && (
|
||||
<p className="home-page__status home-page__status--error">{t("home.error")}</p>
|
||||
)}
|
||||
|
||||
{state.status === "loaded" && state.planning === null && (
|
||||
<p className="home-page__status">{t("home.empty")}</p>
|
||||
)}
|
||||
|
||||
{state.status === "loaded" && state.planning !== null && (
|
||||
<table className="planning-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("home.table.day")}</th>
|
||||
<th>{t("home.table.meal")}</th>
|
||||
<th>{t("home.table.recipe")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{state.planning.items.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td>{item.weekDay}</td>
|
||||
<td>{item.meal}</td>
|
||||
<td>{item.recipe.name}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
318
apps/web/src/pages/PlanningPage.tsx
Normal file
318
apps/web/src/pages/PlanningPage.tsx
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
import {
|
||||
DateTime,
|
||||
addWeeks,
|
||||
buildCalendarMonth,
|
||||
formatDateOnly,
|
||||
getWeekStart,
|
||||
toDateOnly,
|
||||
} from "@batch-cooking/date-tools";
|
||||
import { MEALS, type Meal, type PlanningView, WEEK_DAYS } from "@batch-cooking/shared";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { apiClient } from "../api/client";
|
||||
import "./planning-page.scss";
|
||||
|
||||
/** Load state for the `GET /planning` call — a discriminated union so a stale/impossible combination (e.g. "loading" with data) can't be represented. */
|
||||
type PlanningState =
|
||||
| { status: "loading" }
|
||||
| { status: "loaded"; planning: PlanningView | null }
|
||||
| { status: "error" };
|
||||
|
||||
/** Meals that close out a "moment of the day" group (Matin/Midi/Après-midi/Soir) — see `.band-end` in planning-page.scss for the resulting border treatment. */
|
||||
const BAND_END_MEALS: ReadonlySet<Meal> = new Set(["collation", "dejeuner", "gouter"]);
|
||||
|
||||
/**
|
||||
* Landing page for an authenticated visitor — the household's planning for
|
||||
* a selectable week, laid out as a grid (days × meals). Behind
|
||||
* {@link RequireAuth} (via `AppLayout`), so this only renders once a
|
||||
* session is confirmed.
|
||||
*
|
||||
* `null` from the API is a normal, common state (no planning for that week
|
||||
* yet) — unlike the previous single-day table view this replaces, it isn't
|
||||
* rendered as a separate "empty" message: the grid itself, with every cell
|
||||
* showing just its "+" button, already communicates that. The "+" itself
|
||||
* isn't wired to anything yet (no recipe catalog to search — see the
|
||||
* planning page's plan/PR description) — a future task.
|
||||
*/
|
||||
export function PlanningPage() {
|
||||
const { t } = useTranslation();
|
||||
const [weekStart, setWeekStart] = useState<DateTime>(() => getWeekStart(DateTime.utc()));
|
||||
const [state, setState] = useState<PlanningState>({ status: "loading" });
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setState({ status: "loading" });
|
||||
|
||||
apiClient
|
||||
.getPlanningForWeek(formatDateOnly(weekStart))
|
||||
.then((planning) => {
|
||||
if (!cancelled) setState({ status: "loaded", planning });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setState({ status: "error" });
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [weekStart]);
|
||||
|
||||
return (
|
||||
<div className="planning-page">
|
||||
<div className="planning-page__header">
|
||||
<h1>{t("planning.title")}</h1>
|
||||
<WeekNavigator weekStart={weekStart} onChangeWeek={setWeekStart} />
|
||||
</div>
|
||||
|
||||
{state.status === "loading" && (
|
||||
<p className="planning-page__status">{t("planning.loading")}</p>
|
||||
)}
|
||||
|
||||
{state.status === "error" && (
|
||||
<p className="planning-page__status planning-page__status--error">
|
||||
{t("common.loadError")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{state.status === "loaded" && (
|
||||
<PlanningGrid weekStart={weekStart} planning={state.planning} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** "17 au 23 août 2026" — collapses the month/year to just the end date when both ends of the week share it, spells it out on both ends otherwise (e.g. a week straddling two months). */
|
||||
function formatWeekRange(weekStart: DateTime): string {
|
||||
const weekEnd = weekStart.plus({ days: 6 });
|
||||
const sameMonth = weekStart.hasSame(weekEnd, "month");
|
||||
const startLabel = weekStart.toLocaleString(
|
||||
sameMonth ? { day: "numeric" } : { day: "numeric", month: "long" },
|
||||
{ locale: "fr" },
|
||||
);
|
||||
const endLabel = weekEnd.toLocaleString(
|
||||
{ day: "numeric", month: "long", year: "numeric" },
|
||||
{ locale: "fr" },
|
||||
);
|
||||
return `${startLabel} au ${endLabel}`;
|
||||
}
|
||||
|
||||
/** Arrows + clickable label opening {@link CalendarPopover} — the week-selection UI at the top of the page. */
|
||||
function WeekNavigator({
|
||||
weekStart,
|
||||
onChangeWeek,
|
||||
}: {
|
||||
weekStart: DateTime;
|
||||
onChangeWeek: (weekStart: DateTime) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
|
||||
const isThisWeek = weekStart.hasSame(getWeekStart(DateTime.utc()), "day");
|
||||
|
||||
return (
|
||||
<div className="week-nav">
|
||||
<button
|
||||
type="button"
|
||||
className="week-nav__arrow"
|
||||
title={t("planning.weekNav.prevWeek")}
|
||||
onClick={() => onChangeWeek(addWeeks(weekStart, -1))}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="week-nav__label"
|
||||
onClick={() => setIsCalendarOpen((open) => !open)}
|
||||
>
|
||||
📅 {t("planning.weekNav.label", { range: formatWeekRange(weekStart) })}
|
||||
{isThisWeek && <span className="today-badge">{t("planning.weekNav.thisWeek")}</span>}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="week-nav__arrow"
|
||||
title={t("planning.weekNav.nextWeek")}
|
||||
onClick={() => onChangeWeek(addWeeks(weekStart, 1))}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
|
||||
{isCalendarOpen && (
|
||||
<CalendarPopover
|
||||
selectedWeekStart={weekStart}
|
||||
onSelectDay={(day) => {
|
||||
onChangeWeek(getWeekStart(day));
|
||||
setIsCalendarOpen(false);
|
||||
}}
|
||||
onClose={() => setIsCalendarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Month calendar letting the visitor jump to any week at once — selecting a day selects its whole (Monday-first) week. Closes itself on an outside click. */
|
||||
function CalendarPopover({
|
||||
selectedWeekStart,
|
||||
onSelectDay,
|
||||
onClose,
|
||||
}: {
|
||||
selectedWeekStart: DateTime;
|
||||
onSelectDay: (day: DateTime) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// Its own state: browsing to a different month to pick a week there
|
||||
// shouldn't jump back every render — only re-anchors when the popover is
|
||||
// first opened (`selectedWeekStart` at that point), not while it's open.
|
||||
const [visibleMonth, setVisibleMonth] = useState(() => selectedWeekStart.startOf("month"));
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [onClose]);
|
||||
|
||||
const today = toDateOnly(DateTime.utc());
|
||||
const selectedWeekEnd = selectedWeekStart.plus({ days: 6 });
|
||||
const weeks = buildCalendarMonth(visibleMonth);
|
||||
|
||||
return (
|
||||
<div className="calendar-popover" ref={popoverRef}>
|
||||
<div className="calendar-popover__header">
|
||||
<button
|
||||
type="button"
|
||||
title={t("planning.calendar.prevMonth")}
|
||||
onClick={() => setVisibleMonth((month) => month.minus({ months: 1 }))}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<span>
|
||||
{visibleMonth.toLocaleString({ month: "long", year: "numeric" }, { locale: "fr" })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
title={t("planning.calendar.nextMonth")}
|
||||
onClick={() => setVisibleMonth((month) => month.plus({ months: 1 }))}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="calendar-grid">
|
||||
{WEEK_DAYS.map((weekDay) => (
|
||||
<span key={weekDay} className="calendar-grid__weekday">
|
||||
{t(`planning.days.${weekDay}`).charAt(0)}
|
||||
</span>
|
||||
))}
|
||||
|
||||
{weeks.flat().map((day) => {
|
||||
const classNames = ["calendar-grid__day"];
|
||||
if (!day.hasSame(visibleMonth, "month")) classNames.push("calendar-grid__day--muted");
|
||||
if (day >= selectedWeekStart && day <= selectedWeekEnd) {
|
||||
classNames.push("calendar-grid__day--in-selected-week");
|
||||
}
|
||||
if (day.hasSame(today, "day")) classNames.push("calendar-grid__day--today");
|
||||
|
||||
return (
|
||||
<button
|
||||
key={day.toISO()}
|
||||
type="button"
|
||||
className={classNames.join(" ")}
|
||||
onClick={() => onSelectDay(day)}
|
||||
>
|
||||
{day.day}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The week grid itself — 7 day columns × 5 meal rows. */
|
||||
function PlanningGrid({
|
||||
weekStart,
|
||||
planning,
|
||||
}: { weekStart: DateTime; planning: PlanningView | null }) {
|
||||
const { t } = useTranslation();
|
||||
const today = toDateOnly(DateTime.utc());
|
||||
const days = WEEK_DAYS.map((weekDay, i) => ({ weekDay, date: weekStart.plus({ days: i }) }));
|
||||
const items = planning?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="planning-grid-wrapper">
|
||||
<table className="planning-grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
{days.map(({ weekDay, date }) => (
|
||||
<th key={weekDay} className={date.hasSame(today, "day") ? "today" : undefined}>
|
||||
<span className="day-name">{t(`planning.days.${weekDay}`)}</span>
|
||||
<span className="day-date">{date.day}</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{MEALS.map((meal) => (
|
||||
<tr key={meal} className={BAND_END_MEALS.has(meal) ? "band-end" : undefined}>
|
||||
<th>{t(`planning.meals.${meal}`)}</th>
|
||||
{days.map(({ weekDay, date }) => (
|
||||
<MealCell
|
||||
key={weekDay}
|
||||
isToday={date.hasSame(today, "day")}
|
||||
recipes={items
|
||||
.filter((item) => item.weekDay === weekDay && item.meal === meal)
|
||||
.map((item) => ({ id: item.id, name: item.recipe.name }))}
|
||||
/>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One (day, meal) cell: the recipes already planned for it (as pills) plus the "+" to add another. */
|
||||
function MealCell({
|
||||
isToday,
|
||||
recipes,
|
||||
}: {
|
||||
isToday: boolean;
|
||||
recipes: { id: number; name: string }[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<td className={isToday ? "meal-cell today" : "meal-cell"}>
|
||||
<div className="meal-cell__content">
|
||||
{recipes.length > 0 && (
|
||||
<div className="meal-cell__recipes">
|
||||
{recipes.map((recipe) => (
|
||||
<span key={recipe.id} className="recipe-chip">
|
||||
<span className="recipe-chip__name">{recipe.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="recipe-chip__remove"
|
||||
title={t("planning.grid.removeRecipe")}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button type="button" className="add-recipe-btn" title={t("planning.grid.addRecipeSoon")}>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
356
apps/web/src/pages/planning-page.scss
Normal file
356
apps/web/src/pages/planning-page.scss
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
// =============================================================================
|
||||
// Styles specific to PlanningPage — colocated next to PlanningPage.tsx since
|
||||
// nothing else uses these classes. Ported from the reviewed HTML mockup
|
||||
// (see the plan file / PR description) onto the app's real design tokens —
|
||||
// no light/dark duplication needed here, unlike the standalone mockup:
|
||||
// every `var(--color-*)` below already resolves per-theme globally (see
|
||||
// styles/_theme.scss).
|
||||
// =============================================================================
|
||||
|
||||
// `.app-content` (AppLayout.scss) already stretches to the full viewport
|
||||
// height (flex item of `.app-layout`, itself `min-height: 100vh` — the
|
||||
// same stretch the sidebar relies on to pin its footer at the bottom).
|
||||
// `.planning-page` just needs to fill that box and lay out as a column so
|
||||
// `.planning-grid-wrapper` can grow to fill whatever's left under the
|
||||
// header, instead of the grid being only as tall as its content.
|
||||
.planning-page {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&__header {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
&__status {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-md);
|
||||
}
|
||||
|
||||
&__status--error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Week navigator (arrows + clickable label opening the calendar) -------
|
||||
.week-nav {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
|
||||
&__arrow {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-base);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-md);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-alt);
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: 0.45rem var(--space-md);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-base);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-alt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.today-badge {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
// --- Calendar popover -------------------------------------------------------
|
||||
.calendar-popover {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--space-xs));
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
width: 18rem;
|
||||
padding: var(--space-md);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-md);
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-sm);
|
||||
font-weight: 700;
|
||||
font-size: var(--font-size-sm);
|
||||
text-transform: capitalize;
|
||||
|
||||
button {
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--color-text-muted);
|
||||
border-radius: var(--radius-base);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-alt);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 2px;
|
||||
|
||||
&__weekday {
|
||||
text-align: center;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 600;
|
||||
padding-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
&__day {
|
||||
aspect-ratio: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: var(--font-size-sm);
|
||||
border-radius: var(--radius-base);
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
border: none;
|
||||
background: none;
|
||||
font: inherit;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-alt);
|
||||
}
|
||||
|
||||
&--muted {
|
||||
color: var(--color-border);
|
||||
}
|
||||
|
||||
&--in-selected-week {
|
||||
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
&--today {
|
||||
box-shadow: inset 0 0 0 2px var(--color-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- The grid itself --------------------------------------------------------
|
||||
.planning-grid-wrapper {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
table.planning-grid {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 62rem;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
|
||||
th,
|
||||
td {
|
||||
border: 1px solid var(--color-border);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
thead th {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--color-surface-alt);
|
||||
text-align: left;
|
||||
|
||||
&:first-child {
|
||||
width: 9rem;
|
||||
}
|
||||
|
||||
.day-name {
|
||||
display: block;
|
||||
font-size: var(--font-size-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.day-date {
|
||||
display: block;
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 700;
|
||||
margin-top: 2px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&.today {
|
||||
background: color-mix(in srgb, var(--color-primary) 10%, var(--color-surface-alt));
|
||||
|
||||
.day-date {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tbody th {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--color-surface-alt);
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
td.today {
|
||||
background: color-mix(in srgb, var(--color-primary) 4%, var(--color-surface));
|
||||
}
|
||||
|
||||
// Repas groupés par moment de la journée (Matin / Midi / Après-midi /
|
||||
// Soir) — piloté uniquement via `border-bottom` (jamais `border-top`) :
|
||||
// avec `border-collapse: collapse`, deux bordures différentes qui se
|
||||
// rencontrent sur la même arête peuvent fusionner de façon ambiguë selon
|
||||
// le navigateur — en désactivant `border-top` sur tbody, chaque arête
|
||||
// horizontale n'est plus définie que d'un seul côté, sans ambiguïté
|
||||
// possible. Toutes les séparations entre repas sont pleines (même couleur
|
||||
// que les séparations de jour) ; seule la frontière entre deux groupes
|
||||
// ("band-end", la dernière ligne d'un groupe) se distingue par une
|
||||
// épaisseur plus marquée.
|
||||
tbody th,
|
||||
tbody td {
|
||||
border-top: none;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
tbody tr.band-end th,
|
||||
tbody tr.band-end td {
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Case : pastilles de recette + bouton "+" -------------------------------
|
||||
.meal-cell {
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.meal-cell__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.meal-cell__recipes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.recipe-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.3rem var(--space-sm);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-tag);
|
||||
color: var(--color-tag-ink);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
|
||||
&__name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__remove {
|
||||
flex-shrink: 0;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
opacity: 0;
|
||||
font-size: 0.65rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&:hover &__remove {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&__remove:hover {
|
||||
opacity: 1;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
// Pleine largeur, sans bordure (juste un fond au survol — plus épuré qu'un
|
||||
// contour pointillé), et toujours collé en haut de la case (juste sous la
|
||||
// dernière recette s'il y en a), jamais centré au milieu d'une case vide.
|
||||
.add-recipe-btn {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.35rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: var(--radius-base);
|
||||
background: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-base);
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||
}
|
||||
}
|
||||
|
|
@ -66,7 +66,9 @@ export function HouseholdSettingsPage() {
|
|||
return (
|
||||
<div className="settings-page">
|
||||
<h1>{t("household.title")}</h1>
|
||||
<p className="settings-page__status settings-page__status--error">{t("home.error")}</p>
|
||||
<p className="settings-page__status settings-page__status--error">
|
||||
{t("common.loadError")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -129,7 +129,9 @@ export function PreferencesPage() {
|
|||
return (
|
||||
<div className="settings-page">
|
||||
<h1>{t("preferences.title")}</h1>
|
||||
<p className="settings-page__status settings-page__status--error">{t("home.error")}</p>
|
||||
<p className="settings-page__status settings-page__status--error">
|
||||
{t("common.loadError")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
26
packages/date-tools/package.json
Normal file
26
packages/date-tools/package.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"name": "@batch-cooking/date-tools",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "echo \"no tests yet\" && exit 0",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"postinstall": "tsc -p tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/luxon": "^3.4.2",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"luxon": "^3.5.0"
|
||||
}
|
||||
}
|
||||
47
packages/date-tools/src/date-only.ts
Normal file
47
packages/date-tools/src/date-only.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { DateTime } from "luxon";
|
||||
|
||||
// A "date-only" value here always means UTC midnight — Prisma's `@db.Date`
|
||||
// columns (`Planning.startDate`/`finishDate`, see apps/api's schema.prisma)
|
||||
// carry no time-of-day, so every comparison/computation on them needs to be
|
||||
// anchored the same way to stay meaningful. `DateTime` (Luxon) is the
|
||||
// in-memory representation everywhere in this package; plain `Date`/ISO
|
||||
// `string` only ever appear at the two boundaries that require them —
|
||||
// Prisma (`Date`) and URLs/query strings (`string`).
|
||||
|
||||
/**
|
||||
* Parses a strict `YYYY-MM-DD` string into a UTC-midnight {@link DateTime}.
|
||||
* Returns `null` for anything that isn't a real calendar date — including a
|
||||
* value that's merely shaped right but impossible (e.g. `2026-02-30`),
|
||||
* unlike native `Date` which would silently roll it over to March 2nd.
|
||||
*/
|
||||
export function parseDateOnly(iso: string): DateTime | null {
|
||||
const parsed = DateTime.fromISO(iso, { zone: "utc" });
|
||||
return parsed.isValid ? parsed.startOf("day") : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a {@link DateTime} back to `YYYY-MM-DD`, the inverse of
|
||||
* {@link parseDateOnly}.
|
||||
*
|
||||
* @throws if `date` is an invalid `DateTime` — every `DateTime` produced by
|
||||
* this package's own functions is always valid, so this only fires if a
|
||||
* caller constructs one by hand incorrectly.
|
||||
*/
|
||||
export function formatDateOnly(date: DateTime): string {
|
||||
const iso = date.toISODate();
|
||||
if (iso === null) {
|
||||
throw new Error("Cannot format an invalid DateTime as a date-only string");
|
||||
}
|
||||
return iso;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a `Date` (e.g. a value read back from Prisma) or a
|
||||
* `DateTime` in any zone/with any time-of-day to a UTC-midnight
|
||||
* {@link DateTime} — the common representation every other function in
|
||||
* this package expects and returns.
|
||||
*/
|
||||
export function toDateOnly(date: Date | DateTime): DateTime {
|
||||
const dateTime = date instanceof DateTime ? date : DateTime.fromJSDate(date, { zone: "utc" });
|
||||
return dateTime.toUTC().startOf("day");
|
||||
}
|
||||
12
packages/date-tools/src/index.ts
Normal file
12
packages/date-tools/src/index.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// Public entry point of the date-handling utilities shared between apps/api
|
||||
// and apps/web — every date computation in the monorepo (parsing/formatting
|
||||
// `YYYY-MM-DD` values, week/calendar math) goes through Luxon `DateTime` via
|
||||
// this package rather than hand-rolled `Date` arithmetic or a second,
|
||||
// differently-behaved date library creeping into one side only.
|
||||
|
||||
export * from "./date-only.js";
|
||||
export * from "./week.js";
|
||||
|
||||
// Re-exported so a consumer never needs its own direct `luxon` dependency
|
||||
// just to type a `DateTime` value passed to/from this package's functions.
|
||||
export { DateTime } from "luxon";
|
||||
40
packages/date-tools/src/week.ts
Normal file
40
packages/date-tools/src/week.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import type { DateTime } from "luxon";
|
||||
|
||||
/**
|
||||
* The Monday of the week containing `date` (UTC midnight, same time-of-day
|
||||
* handling as `date-only.ts`). Luxon's `startOf("week")` is Monday-first by
|
||||
* default (ISO 8601 week numbering) regardless of locale, which already
|
||||
* matches the French week this app uses — no locale option needed.
|
||||
*/
|
||||
export function getWeekStart(date: DateTime): DateTime {
|
||||
return date.startOf("week");
|
||||
}
|
||||
|
||||
/** Shifts `date` by `n` weeks (negative to go back) — `date` need not already be a week start. */
|
||||
export function addWeeks(date: DateTime, n: number): DateTime {
|
||||
return date.plus({ weeks: n });
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a fixed 6×7 (weeks × days, Monday-first) calendar grid covering
|
||||
* `month`, the same shape every month-picker UI in this app should use —
|
||||
* always 6 rows regardless of how many weeks the month actually spans, so
|
||||
* the grid never resizes/reflows switching between months. Leading/trailing
|
||||
* days from the adjacent month are included (a caller distinguishes them
|
||||
* with `day.hasSame(month, "month")`), not omitted.
|
||||
*/
|
||||
export function buildCalendarMonth(month: DateTime): DateTime[][] {
|
||||
const gridStart = getWeekStart(month.startOf("month"));
|
||||
|
||||
const weeks: DateTime[][] = [];
|
||||
let cursor = gridStart;
|
||||
for (let week = 0; week < 6; week++) {
|
||||
const days: DateTime[] = [];
|
||||
for (let day = 0; day < 7; day++) {
|
||||
days.push(cursor);
|
||||
cursor = cursor.plus({ days: 1 });
|
||||
}
|
||||
weeks.push(days);
|
||||
}
|
||||
return weeks;
|
||||
}
|
||||
11
packages/date-tools/tsconfig.json
Normal file
11
packages/date-tools/tsconfig.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ export * from "./errors/error-codes.js";
|
|||
export * from "./schemas/account.js";
|
||||
export * from "./schemas/auth.js";
|
||||
export * from "./schemas/household.js";
|
||||
export * from "./schemas/planning.js";
|
||||
export * from "./schemas/profile.js";
|
||||
export * from "./tools/assert-is-never.js";
|
||||
export * from "./types/household.js";
|
||||
|
|
|
|||
17
packages/shared/src/schemas/planning.ts
Normal file
17
packages/shared/src/schemas/planning.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { z } from "zod";
|
||||
|
||||
// See schemas/auth.ts for the shared client/server validation rationale.
|
||||
|
||||
/**
|
||||
* Payload accepted by `GET /planning`'s `?date=` query param. Only checks
|
||||
* the `YYYY-MM-DD` *shape* — whether it's a real calendar date (e.g.
|
||||
* rejecting `2026-02-30`) is checked service-side via
|
||||
* `@batch-cooking/date-tools`'s `parseDateOnly`, not here: `packages/shared`
|
||||
* has no runtime dependencies of its own, and pulling in a date library just
|
||||
* for this one check isn't worth losing that.
|
||||
*/
|
||||
export const getPlanningByDateSchema = z.object({
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date invalide"),
|
||||
});
|
||||
/** Inferred TS type for {@link getPlanningByDateSchema}'s validated output. */
|
||||
export type GetPlanningByDateInput = z.infer<typeof getPlanningByDateSchema>;
|
||||
|
|
@ -1,3 +1,32 @@
|
|||
/**
|
||||
* The 7 values `PlanningItemView.weekDay` is expected to take — lowercase,
|
||||
* unaccented French day names. Not enforced by the database (`week_day` is
|
||||
* a plain `String` column, see schema.prisma) or by any write endpoint yet
|
||||
* (there isn't one), but this is the contract the planning grid
|
||||
* (`apps/web`'s `PlanningPage`) reads against, and the one a future
|
||||
* "add a recipe to a slot" endpoint should write.
|
||||
*/
|
||||
export const WEEK_DAYS = [
|
||||
"lundi",
|
||||
"mardi",
|
||||
"mercredi",
|
||||
"jeudi",
|
||||
"vendredi",
|
||||
"samedi",
|
||||
"dimanche",
|
||||
] as const;
|
||||
/** Inferred TS type for one {@link WEEK_DAYS} member. */
|
||||
export type WeekDay = (typeof WEEK_DAYS)[number];
|
||||
|
||||
/**
|
||||
* The 5 values `PlanningItemView.meal` is expected to take, in day order —
|
||||
* same "documented but not enforced yet" status as {@link WEEK_DAYS}, same
|
||||
* reason.
|
||||
*/
|
||||
export const MEALS = ["petit-dejeuner", "collation", "dejeuner", "gouter", "diner"] as const;
|
||||
/** Inferred TS type for one {@link MEALS} member. */
|
||||
export type Meal = (typeof MEALS)[number];
|
||||
|
||||
/**
|
||||
* A single meal slot within a household's planning, with its recipe
|
||||
* resolved to just enough info for display (id + name) — a caller needing
|
||||
|
|
@ -5,9 +34,9 @@
|
|||
*/
|
||||
export interface PlanningItemView {
|
||||
id: number;
|
||||
/** Day of the week this item falls on (free-form for now — no enum exists yet, see schema.prisma). */
|
||||
/** Day of the week this item falls on — see {@link WeekDay} (free-form for now — no enum exists yet, see schema.prisma). */
|
||||
weekDay: string;
|
||||
/** Which meal of the day this item is for (free-form for now, same reason). */
|
||||
/** Which meal of the day this item is for — see {@link Meal} (free-form for now, same reason). */
|
||||
meal: string;
|
||||
recipe: {
|
||||
id: number;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ importers:
|
|||
|
||||
apps/api:
|
||||
dependencies:
|
||||
'@batch-cooking/date-tools':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/date-tools
|
||||
'@batch-cooking/error-tools':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/error-tools
|
||||
|
|
@ -87,6 +90,9 @@ importers:
|
|||
|
||||
apps/web:
|
||||
dependencies:
|
||||
'@batch-cooking/date-tools':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/date-tools
|
||||
'@batch-cooking/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/shared
|
||||
|
|
@ -137,6 +143,19 @@ importers:
|
|||
specifier: ^5.4.11
|
||||
version: 5.4.21(@types/node@22.20.1)(sass@1.102.0)
|
||||
|
||||
packages/date-tools:
|
||||
dependencies:
|
||||
luxon:
|
||||
specifier: ^3.5.0
|
||||
version: 3.7.2
|
||||
devDependencies:
|
||||
'@types/luxon':
|
||||
specifier: ^3.4.2
|
||||
version: 3.7.4
|
||||
typescript:
|
||||
specifier: ^5.7.2
|
||||
version: 5.9.3
|
||||
|
||||
packages/error-tools:
|
||||
dependencies:
|
||||
'@batch-cooking/shared':
|
||||
|
|
@ -1055,6 +1074,9 @@ packages:
|
|||
'@types/jsonwebtoken@9.0.10':
|
||||
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==, tarball: https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz}
|
||||
|
||||
'@types/luxon@3.7.4':
|
||||
resolution: {integrity: sha512-V536ZAd6ZJztrrBlLcDFaaZrXNAL2E5uGmssWf/dpSiLkmkLScXUYhUBnWPmtW+cIqnNHzf6//TCMpIc9SCRRQ==, tarball: https://registry.npmjs.org/@types/luxon/-/luxon-3.7.4.tgz}
|
||||
|
||||
'@types/methods@1.1.4':
|
||||
resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==, tarball: https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz}
|
||||
|
||||
|
|
@ -3617,6 +3639,8 @@ snapshots:
|
|||
'@types/ms': 2.1.0
|
||||
'@types/node': 22.20.1
|
||||
|
||||
'@types/luxon@3.7.4': {}
|
||||
|
||||
'@types/methods@1.1.4': {}
|
||||
|
||||
'@types/mime@1.3.5': {}
|
||||
|
|
|
|||
Loading…
Reference in a new issue