diff --git a/apps/api/src/routes/planning.ts b/apps/api/src/routes/planning.ts index 5d1df60..f5bcde1 100644 --- a/apps/api/src/routes/planning.ts +++ b/apps/api/src/routes/planning.ts @@ -2,6 +2,7 @@ import type { FastifyInstance } from "fastify"; import { and, desc, eq, gte, sql } from "drizzle-orm"; import { schema } from "@app/database"; import { + addPlanEntryInputSchema, generateWeekPlanInputSchema, idParamSchema, updatePlanEntryInputSchema, @@ -178,6 +179,64 @@ export async function planningRoutes(app: FastifyInstance) { return { ok: true }; }); + // Lägg till en enskild måltid i planen (t.ex. på en tom dag). + app.post("/v1/week-plans/:id/entries", auth, async (req, reply) => { + const params = req.params as { id: string }; + const [plan] = await app.db + .select() + .from(schema.weekPlans) + .where(eq(schema.weekPlans.id, params.id)) + .limit(1); + if (!plan) throw errors.notFound("Planen finns inte."); + await requireMembership(app.db, plan.householdId, req.userId); + + const input = parse(addPlanEntryInputSchema, req.body); + const [recipe] = await app.db + .select({ + id: schema.recipes.id, + titleSv: schema.recipes.titleSv, + portions: schema.recipes.portions, + }) + .from(schema.recipes) + .where(and(eq(schema.recipes.id, input.recipeId), eq(schema.recipes.status, "published"))) + .limit(1); + if (!recipe) throw errors.notFound("Receptet finns inte."); + + const sameDay = await app.db + .select({ sortOrder: schema.weekPlanEntries.sortOrder }) + .from(schema.weekPlanEntries) + .where( + and( + eq(schema.weekPlanEntries.weekPlanId, params.id), + eq(schema.weekPlanEntries.date, input.date), + ), + ); + const nextSort = sameDay.reduce((m, e) => Math.max(m, e.sortOrder ?? 0), -1) + 1; + + const [row] = await app.db + .insert(schema.weekPlanEntries) + .values({ + weekPlanId: params.id, + date: input.date, + mealType: input.mealType, + recipeId: recipe.id, + titleSv: recipe.titleSv, + portions: recipe.portions ?? 2, + status: "planned", + sortOrder: nextSort, + }) + .returning(); + + await emitEvent(app.db, { + type: "WEEK_PLAN_UPDATED", + payload: { weekPlanId: params.id, reason: "entry_added" }, + userId: req.userId, + householdId: plan.householdId, + correlationId: req.correlationId, + }); + return reply.status(201).send(row); + }); + app.post("/v1/week-plans/:id/activate", auth, async (req) => { const { id } = parse(idParamSchema, req.params); const [plan] = await app.db diff --git a/apps/mobile/src/app/(tabs)/plan.tsx b/apps/mobile/src/app/(tabs)/plan.tsx index 5ca28e9..21a8dae 100644 --- a/apps/mobile/src/app/(tabs)/plan.tsx +++ b/apps/mobile/src/app/(tabs)/plan.tsx @@ -69,21 +69,38 @@ export default function PlanScreen() { const queryClient = useQueryClient(); const [selectedKey, setSelectedKey] = useState(() => toKey(new Date())); const [actionEntry, setActionEntry] = useState(null); + const [addMealOpen, setAddMealOpen] = useState(false); const [generating, setGenerating] = useState(false); + const [weekOffset, setWeekOffset] = useState(0); // 0 = denna vecka, 1 = nästa vecka … const todayKey = toKey(new Date()); const selected = useMemo(() => new Date(`${selectedKey}T00:00:00`), [selectedKey]); - // Veckoplanen startar alltid idag och går 7 dagar framåt (aldrig bakåt i tiden). - const monday = useMemo(() => { + // Planeringsfönster: 7 dagar från veckostart (idag + veckoförskjutning). + // Aldrig bakåt i tiden – weekOffset klampas till >= 0. + const todayMidnight = useMemo(() => { const d = new Date(); d.setHours(0, 0, 0, 0); return d; }, []); - const mondayKey = toKey(monday); - const weekDays = useMemo(() => Array.from({ length: 7 }, (_, i) => addDays(monday, i)), [monday]); + const weekStart = useMemo( + () => addDays(todayMidnight, weekOffset * 7), + [todayMidnight, weekOffset], + ); + const weekStartKey = toKey(weekStart); + const weekDays = useMemo( + () => Array.from({ length: 7 }, (_, i) => addDays(weekStart, i)), + [weekStart], + ); + // Byt vecka (framåt/bakåt). Sätter markerad dag till första dagen i veckan. + const goWeek = (delta: number) => + setWeekOffset((o) => { + const next = Math.max(0, o + delta); + setSelectedKey(toKey(addDays(todayMidnight, next * 7))); + return next; + }); // Lunch + middag varje dag som default (fler fyllda måltider), går att toggla. const [mealSlots, setMealSlots] = useState(() => - weekDays.map(() => ["lunch", "dinner"]), + Array.from({ length: 7 }, () => ["lunch", "dinner"]), ); const toggleSlot = (dayIdx: number, meal: string) => setMealSlots((prev) => @@ -93,8 +110,8 @@ export default function PlanScreen() { ); const query = useQuery({ - queryKey: ["week-plan", mondayKey], - queryFn: () => api(`/v1/week-plans?weekStartDate=${mondayKey}`), + queryKey: ["week-plan", weekStartKey], + queryFn: () => api(`/v1/week-plans?weekStartDate=${weekStartKey}`), refetchInterval: generating ? 3000 : false, }); const plan = query.data?.plans?.[0] ?? null; @@ -129,7 +146,7 @@ export default function PlanScreen() { return () => clearTimeout(timer); }, [generating]); - const invalidate = () => queryClient.invalidateQueries({ queryKey: ["week-plan", mondayKey] }); + const invalidate = () => queryClient.invalidateQueries({ queryKey: ["week-plan", weekStartKey] }); const onMutationError = (err: unknown) => Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")); @@ -137,7 +154,7 @@ export default function PlanScreen() { mutationFn: () => api("/v1/week-plans/generate", { method: "POST", - body: { weekStartDate: mondayKey, mealTypes: ["lunch", "dinner"], mealSlots }, + body: { weekStartDate: weekStartKey, mealTypes: ["lunch", "dinner"], mealSlots }, }), onSuccess: () => setGenerating(true), onError: onMutationError, @@ -183,9 +200,11 @@ export default function PlanScreen() { if (planId) setActionEntry(entry); }; - const monthLabel = cap( - new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(selected), - ); + const weekRangeLabel = `${new Intl.DateTimeFormat(locale, { day: "numeric" }).format( + weekStart, + )}–${new Intl.DateTimeFormat(locale, { day: "numeric", month: "short" }).format( + addDays(weekStart, 6), + )}`; const dayHeader = cap( new Intl.DateTimeFormat(locale, { weekday: "long", day: "numeric", month: "long" }).format( selected, @@ -196,13 +215,18 @@ export default function PlanScreen() { return ( - setSelectedKey(toKey(addDays(selected, -7)))} hitSlop={8}> + goWeek(-1)} + hitSlop={8} + disabled={weekOffset === 0} + style={{ opacity: weekOffset === 0 ? 0.3 : 1 }} + > - {monthLabel} - setSelectedKey(toKey(addDays(selected, 7)))} hitSlop={8}> + {weekRangeLabel} + goWeek(1)} hitSlop={8}> @@ -210,7 +234,10 @@ export default function PlanScreen() { setSelectedKey(todayKey)} + onPress={() => { + setWeekOffset(0); + setSelectedKey(todayKey); + }} style={{ alignSelf: "flex-start", paddingVertical: spacing.xs }} > @@ -356,6 +383,18 @@ export default function PlanScreen() { )} + {/* Lägg till en måltid på vald dag (även tom dag), så länge en plan finns. */} + {planId && !generating ? ( + <> + +