Nouveau package date-tools (Luxon) + contrat jours/repas partagé (step 1/4)
- packages/date-tools: parseDateOnly/formatDateOnly/toDateOnly, getWeekStart/addWeeks/buildCalendarMonth, sur Luxon DateTime (UTC) - packages/shared: WEEK_DAYS/WeekDay, MEALS/Meal (contrat de valeurs documenté pour PlanningItemView.weekDay/.meal, pas encore enforcé en base), getPlanningByDateSchema (validation de forme de ?date=)
This commit is contained in:
parent
26453dd757
commit
b930453878
9 changed files with 209 additions and 2 deletions
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/account.js";
|
||||||
export * from "./schemas/auth.js";
|
export * from "./schemas/auth.js";
|
||||||
export * from "./schemas/household.js";
|
export * from "./schemas/household.js";
|
||||||
|
export * from "./schemas/planning.js";
|
||||||
export * from "./schemas/profile.js";
|
export * from "./schemas/profile.js";
|
||||||
export * from "./tools/assert-is-never.js";
|
export * from "./tools/assert-is-never.js";
|
||||||
export * from "./types/household.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
|
* A single meal slot within a household's planning, with its recipe
|
||||||
* resolved to just enough info for display (id + name) — a caller needing
|
* resolved to just enough info for display (id + name) — a caller needing
|
||||||
|
|
@ -5,9 +34,9 @@
|
||||||
*/
|
*/
|
||||||
export interface PlanningItemView {
|
export interface PlanningItemView {
|
||||||
id: number;
|
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;
|
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;
|
meal: string;
|
||||||
recipe: {
|
recipe: {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ importers:
|
||||||
|
|
||||||
apps/api:
|
apps/api:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@batch-cooking/date-tools':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/date-tools
|
||||||
'@batch-cooking/error-tools':
|
'@batch-cooking/error-tools':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/error-tools
|
version: link:../../packages/error-tools
|
||||||
|
|
@ -87,6 +90,9 @@ importers:
|
||||||
|
|
||||||
apps/web:
|
apps/web:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@batch-cooking/date-tools':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/date-tools
|
||||||
'@batch-cooking/shared':
|
'@batch-cooking/shared':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/shared
|
version: link:../../packages/shared
|
||||||
|
|
@ -137,6 +143,19 @@ importers:
|
||||||
specifier: ^5.4.11
|
specifier: ^5.4.11
|
||||||
version: 5.4.21(@types/node@22.20.1)(sass@1.102.0)
|
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:
|
packages/error-tools:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@batch-cooking/shared':
|
'@batch-cooking/shared':
|
||||||
|
|
@ -1055,6 +1074,9 @@ packages:
|
||||||
'@types/jsonwebtoken@9.0.10':
|
'@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}
|
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':
|
'@types/methods@1.1.4':
|
||||||
resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==, tarball: https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz}
|
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/ms': 2.1.0
|
||||||
'@types/node': 22.20.1
|
'@types/node': 22.20.1
|
||||||
|
|
||||||
|
'@types/luxon@3.7.4': {}
|
||||||
|
|
||||||
'@types/methods@1.1.4': {}
|
'@types/methods@1.1.4': {}
|
||||||
|
|
||||||
'@types/mime@1.3.5': {}
|
'@types/mime@1.3.5': {}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue