diff --git a/packages/date-tools/package.json b/packages/date-tools/package.json new file mode 100644 index 0000000..9d3ac09 --- /dev/null +++ b/packages/date-tools/package.json @@ -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" + } +} diff --git a/packages/date-tools/src/date-only.ts b/packages/date-tools/src/date-only.ts new file mode 100644 index 0000000..05099b9 --- /dev/null +++ b/packages/date-tools/src/date-only.ts @@ -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"); +} diff --git a/packages/date-tools/src/index.ts b/packages/date-tools/src/index.ts new file mode 100644 index 0000000..be425aa --- /dev/null +++ b/packages/date-tools/src/index.ts @@ -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"; diff --git a/packages/date-tools/src/week.ts b/packages/date-tools/src/week.ts new file mode 100644 index 0000000..02690bb --- /dev/null +++ b/packages/date-tools/src/week.ts @@ -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; +} diff --git a/packages/date-tools/tsconfig.json b/packages/date-tools/tsconfig.json new file mode 100644 index 0000000..fb47b20 --- /dev/null +++ b/packages/date-tools/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 95aa7b9..3a7f5e4 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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"; diff --git a/packages/shared/src/schemas/planning.ts b/packages/shared/src/schemas/planning.ts new file mode 100644 index 0000000..31a28ec --- /dev/null +++ b/packages/shared/src/schemas/planning.ts @@ -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; diff --git a/packages/shared/src/types/planning.ts b/packages/shared/src/types/planning.ts index 74a7c99..271b75e 100644 --- a/packages/shared/src/types/planning.ts +++ b/packages/shared/src/types/planning.ts @@ -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; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2efbb3..a842ad5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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': {}