feat: anonymous read-only public link + QR code for meal plans (v0.68.0)

Mirrors shopping lists' isPublic pattern but read-only only -- no
publicEditable equivalent, since anonymous editing of someone's
weekly meal schedule isn't a real use case the way collaborative
shopping-list editing is. New PATCH /api/v1/meal-plans/{weekStart}
toggles it (lazily creates the week's plan row if missing, same
pattern as the existing POST). Public page at /m/[id] renders the
week grouped by day, linking through to public/unlisted recipes and
just showing the title otherwise.

ShareMealPlanButton gained the same public-link toggle UI as its
shopping-list sibling, plus a QR code (generated client-side via the
already-installed `qrcode` package) for the link once enabled --
handing someone a printed or screenshotted plan doesn't require
typing a URL.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-21 23:47:31 +02:00
parent c8ee743458
commit 35d4f3d055
17 changed files with 6282 additions and 11 deletions
+8 -1
View File
@@ -1,5 +1,5 @@
// Mirrors CHANGELOG.md at the repo root — update both together.
export const APP_VERSION = "0.67.0";
export const APP_VERSION = "0.68.0";
export type ChangelogEntry = {
version: string;
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.68.0",
date: "2026-07-21 10:00",
added: [
"Meal plans can now be shared with a read-only anonymous public link (Share -> Public link), same idea as shopping lists' public link but view-only, plus a QR code you can screenshot or print to hand someone the week's plan.",
],
},
{
version: "0.67.0",
date: "2026-07-21 09:30",
+2 -1
View File
@@ -207,7 +207,7 @@ export function generateOpenApiSpec(): object {
}));
const MealPlanRef = registry.register("MealPlan", z.object({
id: z.string().optional(), userId: z.string().optional(), weekStart: z.string(),
createdAt: z.string().datetime().optional(),
isPublic: z.boolean().optional(), createdAt: z.string().datetime().optional(),
entries: z.array(MealPlanFullEntryRef),
}));
@@ -395,6 +395,7 @@ export function generateOpenApiSpec(): object {
registry.registerPath({ method: "delete", path: "/api/v1/collections/{id}/members", summary: "Remove a member (owner or the member themselves)", security, request: { params: idParam, query: z.object({ memberId: z.string() }) }, responses: { 204: { description: "Removed" }, 400: { description: "memberId required", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/meal-plans/{weekStart}", summary: "Get your own meal plan for a week", security, request: { params: weekStartParam }, responses: { 200: { description: "Meal plan with entries", content: { "application/json": { schema: MealPlanRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/meal-plans/{weekStart}", summary: "Create the meal plan for a week (idempotent)", security, request: { params: weekStartParam }, responses: { 200: { description: "Already existed", content: { "application/json": { schema: z.object({ id: z.string(), weekStart: z.string() }) } } }, 201: { description: "Created", content: { "application/json": { schema: z.object({ id: z.string(), weekStart: z.string() }) } } } } });
registry.registerPath({ method: "patch", path: "/api/v1/meal-plans/{weekStart}", summary: "Set whether a week's plan has a public read-only link", description: "Creates the week's plan if it doesn't exist yet. The link is /m/{id} — read-only, unlike shopping lists' public link which can also be made editable.", security, request: { params: weekStartParam, body: { content: { "application/json": { schema: z.object({ isPublic: z.boolean() }) } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ id: z.string(), weekStart: z.string(), isPublic: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
// --- Meal-plan entries, bulk clear, members, nutrition, .ics export, shared plans ---
const CreateMealPlanEntryRef = registry.register("CreateMealPlanEntry", z.object({