fix(matlager): stada upp – flytta lager till egen vy, gruppera per plats, fixa limit-bugg

- BUGG: kitchen hamtade ?limit=500 men schemat tillat max 200 -> 400-fel nar man
  gick in i matlager / tryckte "+N till". Hojer inventoryQuerySchema.limit till 500.
- Hem: tar bort den roriga inline-listan (matlager lag kvar under Hemma). Ersatts
  med ett "Matlager"-kort med genvagsknappar per plats (Kyl/Frys/Skafferi) + "Allt".
- Matlager-skarmen: grupperad per plats med rubrik + antal, sorterad pa namn,
  plats-filter via chips ELLER djuplank (?loc=), sok, markera flera, massradering.
- Byter skarmtitel "Ditt kok" -> "Matlager".
- Fixar aven en latent hooks-ordningsbugg i home (useFocusEffect efter early return).
This commit is contained in:
Claude
2026-08-18 16:02:57 +00:00
parent 610910ccf0
commit 3942890d6f
4 changed files with 168 additions and 148 deletions
+69 -80
View File
@@ -1,7 +1,7 @@
import { Alert, Pressable, View } from "react-native";
import { useCallback } from "react";
import { Pressable, View } from "react-native";
import { useCallback, useMemo } from "react";
import { router, useFocusEffect } from "expo-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth";
import { t } from "@/lib/i18n";
@@ -11,7 +11,6 @@ import {
Body,
Button,
Card,
EmptyState,
ErrorView,
Heading,
LoadingView,
@@ -23,7 +22,6 @@ import {
Title,
} from "@/components/ui";
import { colors, spacing } from "@/lib/theme";
import { formatQuantity } from "@/lib/units";
/** Hemma (spec §4.4): matlager, bäst före, matlådor, inköpslista, budget, hushåll. */
@@ -33,6 +31,7 @@ interface InventoryItem {
quantity: number;
unit: string;
locationName: string;
locationType: string;
expiry: { status: string; daysLeft: number | null; pastBestBefore: boolean };
}
interface BudgetSummary {
@@ -41,24 +40,18 @@ interface BudgetSummary {
month: { purchasedMinor: number; wasteMinor: number };
}
const LOCATION_ORDER = ["fridge", "freezer", "pantry", "garage_freezer", "wine_fridge"];
const orderIndex = (type: string) => {
const i = LOCATION_ORDER.indexOf(type);
return i === -1 ? LOCATION_ORDER.length : i;
};
export default function HomeScreen() {
const queryClient = useQueryClient();
const remove = useMutation({
mutationFn: (id: string) => api(`/v1/inventory/items/${id}`, { method: "DELETE" }),
onError: (err) =>
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")),
onSettled: () => void queryClient.invalidateQueries({ queryKey: ["inventory"] }),
});
const confirmRemove = (id: string, name: string) =>
Alert.alert("Ta bort", `Ta bort ${name} ur ditt lager?`, [
{ text: t("common.cancel"), style: "cancel" },
{ text: "Ta bort", style: "destructive", onPress: () => remove.mutate(id) },
]);
const inventory = useQuery({
queryKey: ["inventory"],
queryFn: () =>
api<{ items: InventoryItem[]; trustStatus?: "up_to_date" | "needs_check" | "uncertain" }>(
"/v1/inventory?limit=100",
"/v1/inventory?limit=200",
),
});
const expiring = useQuery({
@@ -76,21 +69,26 @@ export default function HomeScreen() {
});
const setOnboardingStep = useAuth((s) => s.setOnboardingStep);
useFocusEffect(
useCallback(() => {
void onboardingStatus.refetch();
}, []),
);
const items = inventory.data?.items ?? [];
// Distinkta platser (Kyl/Frys/Skafferi …) för genvägsknappar in i Matlager.
const locations = useMemo(() => {
const seen = new Map<string, string>();
for (const it of items) if (!seen.has(it.locationType)) seen.set(it.locationType, it.locationName);
return [...seen.entries()]
.map(([type, name]) => ({ type, name }))
.sort((a, b) => orderIndex(a.type) - orderIndex(b.type));
}, [items]);
if (inventory.isLoading) return <LoadingView />;
if (inventory.isError) return <ErrorView onRetry={() => void inventory.refetch()} />;
useFocusEffect(useCallback(() => { void onboardingStatus.refetch(); }, []));
const items = inventory.data?.items ?? [];
const urgent = expiring.data?.items ?? [];
const grouped = new Map<string, InventoryItem[]>();
for (const item of items) {
const list = grouped.get(item.locationName) ?? [];
list.push(item);
grouped.set(item.locationName, list);
}
const trustStatus = inventory.data?.trustStatus;
const needsProfile = onboardingStatus.data && !onboardingStatus.data.onboardingCompleted;
const profileStep = onboardingStatus.data?.step ?? "b";
@@ -130,10 +128,42 @@ export default function HomeScreen() {
<Card onPress={() => router.push("/saved-recipes")}>
<Body> Dina sparade recept</Body>
</Card>
<Card onPress={() => router.push("/kitchen")}>
<Body>🧺 Ditt kök</Body>
{/* Matlager städat, öppnas per plats eller som helhet (spec §8). */}
<Card>
<Heading>🧺 {t("home.inventory")}</Heading>
<Small>Allt du har hemma öppna en plats eller sök i hela lagret.</Small>
<Row style={{ marginTop: spacing.xs }}>
{locations.map((loc) => (
<Button
key={loc.type}
label={loc.name}
variant="ghost"
onPress={() => router.push(`/kitchen?loc=${loc.type}`)}
/>
))}
<Button
label={locations.length ? "Allt" : "Öppna"}
variant="primary"
onPress={() => router.push("/kitchen")}
/>
</Row>
</Card>
{urgent.length > 0 && (
<Card>
<Heading> {t("home.useSoon")}</Heading>
{urgent.slice(0, 5).map((item) => (
<Row key={item.id} style={{ justifyContent: "space-between" }}>
<Body>{item.displayName}</Body>
<ExpiryTag expiry={item.expiry} />
</Row>
))}
{/* Mjölkprincipen (spec §13): bäst före ≠ dålig döm aldrig mat i onödan. */}
{urgent.some((i) => i.expiry.pastBestBefore) && <Small>{t("home.useSoonHint")}</Small>}
</Card>
)}
{budget.data && (
<Card>
<Heading>{t("home.budget")}</Heading>
@@ -153,47 +183,6 @@ export default function HomeScreen() {
</Card>
)}
{urgent.length > 0 && (
<Card>
<Heading> {t("home.useSoon")}</Heading>
{urgent.slice(0, 5).map((item) => (
<Row key={item.id} style={{ justifyContent: "space-between" }}>
<Body>{item.displayName}</Body>
<ExpiryTag expiry={item.expiry} />
</Row>
))}
{/* Mjölkprincipen (spec §13): bäst före ≠ dålig döm aldrig mat i onödan. */}
{urgent.some((i) => i.expiry.pastBestBefore) && <Small>{t("home.useSoonHint")}</Small>}
</Card>
)}
<Heading>{t("home.inventory")}</Heading>
{items.length === 0 && <EmptyState text={t("home.emptyInventory")} />}
{[...grouped.entries()].map(([location, locationItems]) => (
<Card key={location}>
<Heading>{location}</Heading>
{locationItems.slice(0, 12).map((item) => (
<Row key={item.id} style={{ justifyContent: "space-between", alignItems: "center" }}>
<Body>
{item.displayName} · {formatQuantity(item.quantity, item.unit)}
</Body>
<Row style={{ alignItems: "center" }}>
<ExpiryTag expiry={item.expiry} />
<Button
label="Ta bort"
variant="ghost"
onPress={() => confirmRemove(item.id, item.displayName)}
/>
</Row>
</Row>
))}
{locationItems.length > 12 && (
<Pressable onPress={() => router.push("/kitchen")}>
<Small>{t("home.moreItems", { count: locationItems.length - 12 })} </Small>
</Pressable>
)}
</Card>
))}
<Spacer />
</Screen>
);
@@ -210,16 +199,16 @@ function QuickLink({
}) {
return (
<Card
onPress={() => {
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
onPress();
}}
style={{ flex: 1, alignItems: "center", paddingVertical: spacing.sm }}
>
onPress={() => {
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
onPress();
}}
style={{ flex: 1, alignItems: "center", paddingVertical: spacing.sm }}
>
<Body>{glyph}</Body>
<Small numberOfLines={1} adjustsFontSizeToFit style={{ textAlign: "center" }}>
{label}
</Small>
{label}
</Small>
</Card>
);
}
+1 -1
View File
@@ -86,7 +86,7 @@ export default function RootLayout() {
<Stack.Screen name="household" options={{ title: t("home.household"), presentation: "modal" }} />
<Stack.Screen name="memory" options={{ title: t("memory.title") }} />
<Stack.Screen name="saved-recipes" options={{ title: "Sparade recept" }} />
<Stack.Screen name="kitchen" options={{ title: "Ditt kök" }} />
<Stack.Screen name="kitchen" options={{ title: "Matlager" }} />
<Stack.Screen name="profile" options={{ title: t("profile.title"), presentation: "modal" }} />
<Stack.Screen
name="paywall"
+97 -66
View File
@@ -1,5 +1,6 @@
import { useState } from "react";
import { Alert, TextInput } from "react-native";
import { useMemo, useState } from "react";
import { Alert, View } from "react-native";
import { useLocalSearchParams } from "expo-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { t } from "@/lib/i18n";
@@ -10,14 +11,20 @@ import {
Card,
EmptyState,
ErrorView,
Heading,
Input,
LoadingView,
Row,
Screen,
Small,
} from "@/components/ui";
import { colors, radius, spacing } from "@/lib/theme";
import { colors, spacing } from "@/lib/theme";
/** Ditt kök: sök, sortera, markera flera och ta bort. Radering = mjuk (kvantitet → 0, historik bevaras). */
/**
* Matlager: allt du har hemma, städat per plats (Kyl/Frys/Skafferi …).
* Sök, filtrera på plats, markera flera och ta bort. Radering = mjuk
* (kvantitet → 0, historik bevaras).
*/
interface InventoryItem {
id: string;
@@ -29,6 +36,7 @@ interface InventoryItem {
expiry: { status: string; daysLeft: number | null; pastBestBefore: boolean };
}
const LOCATION_ORDER = ["fridge", "freezer", "pantry", "garage_freezer", "wine_fridge"];
const LOCATION_LABELS: Record<string, string> = {
fridge: "Kyl",
freezer: "Frys",
@@ -37,11 +45,17 @@ const LOCATION_LABELS: Record<string, string> = {
wine_fridge: "Vinkyl",
};
const locLabel = (type: string) => LOCATION_LABELS[type] ?? type;
const orderIndex = (type: string) => {
const i = LOCATION_ORDER.indexOf(type);
return i === -1 ? LOCATION_ORDER.length : i;
};
export default function KitchenScreen() {
const queryClient = useQueryClient();
const params = useLocalSearchParams<{ loc?: string }>();
const initialLoc = typeof params.loc === "string" ? params.loc : null;
const [search, setSearch] = useState("");
const [locFilter, setLocFilter] = useState<string | null>(null);
const [locFilter, setLocFilter] = useState<string | null>(initialLoc);
const [selected, setSelected] = useState<Set<string>>(new Set());
const query = useQuery({
@@ -61,20 +75,39 @@ export default function KitchenScreen() {
},
});
if (query.isLoading) return <LoadingView />;
if (query.isError) return <ErrorView onRetry={() => void query.refetch()} />;
const all = query.data?.items ?? [];
const locTypes = Array.from(new Set(all.map((i) => i.locationType)));
const locTypes = useMemo(() => {
const set = new Set(all.map((i) => i.locationType));
return [...set].sort((a, b) => orderIndex(a) - orderIndex(b) || a.localeCompare(b, "sv"));
}, [all]);
const q = search.trim().toLowerCase();
const visible = all
.filter((i) => (locFilter ? i.locationType === locFilter : true))
.filter((i) => (q ? i.displayName.toLowerCase().includes(q) : true))
.sort(
(a, b) =>
a.locationName.localeCompare(b.locationName, "sv") ||
a.displayName.localeCompare(b.displayName, "sv"),
);
const visible = useMemo(
() =>
all
.filter((i) => (locFilter ? i.locationType === locFilter : true))
.filter((i) => (q ? i.displayName.toLowerCase().includes(q) : true)),
[all, locFilter, q],
);
const groups = useMemo(() => {
const map = new Map<string, InventoryItem[]>();
for (const it of visible) {
const list = map.get(it.locationType) ?? [];
list.push(it);
map.set(it.locationType, list);
}
return [...map.entries()]
.sort(([a], [b]) => orderIndex(a) - orderIndex(b) || a.localeCompare(b, "sv"))
.map(([type, items]) => ({
type,
items: [...items].sort((x, y) => x.displayName.localeCompare(y.displayName, "sv")),
}));
}, [visible]);
if (query.isLoading) return <LoadingView />;
if (query.isError) return <ErrorView onRetry={() => void query.refetch()} />;
const toggle = (id: string) =>
setSelected((prev) => {
@@ -86,32 +119,23 @@ export default function KitchenScreen() {
const confirmDelete = (ids: string[], label: string) => {
if (ids.length === 0) return;
Alert.alert("Ta bort", `Är du säker på att du vill ta bort ${ids.length} st ur ${label}?`, [
{ text: t("common.cancel"), style: "cancel" },
{ text: "Ta bort", style: "destructive", onPress: () => bulkRemove.mutate(ids) },
]);
Alert.alert(
"Ta bort",
`Är du säker på att du vill ta bort ${ids.length} ${ids.length === 1 ? "vara" : "varor"} ur ${label}?`,
[
{ text: t("common.cancel"), style: "cancel" },
{ text: "Ta bort", style: "destructive", onPress: () => bulkRemove.mutate(ids) },
],
);
};
const emptyLabel = locFilter ? locLabel(locFilter) : q ? "sökningen" : "hela lagret";
return (
<Screen>
<TextInput
value={search}
onChangeText={setSearch}
placeholder="Sök vara…"
placeholderTextColor={colors.textMuted}
style={{
borderWidth: 1,
borderColor: colors.border,
borderRadius: radius.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
color: colors.text,
backgroundColor: colors.surface,
marginBottom: spacing.sm,
}}
/>
<Input value={search} onChangeText={setSearch} placeholder="Sök vara…" autoCorrect={false} />
<Row style={{ flexWrap: "wrap", gap: spacing.xs }}>
<Row>
<Button
label="Alla"
variant={locFilter === null ? "primary" : "ghost"}
@@ -127,13 +151,11 @@ export default function KitchenScreen() {
))}
</Row>
<Row
style={{ justifyContent: "space-between", alignItems: "center", marginVertical: spacing.sm }}
>
<Row style={{ justifyContent: "space-between" }}>
<Small>
{visible.length} varor{selected.size > 0 ? ` · ${selected.size} valda` : ""}
</Small>
<Row style={{ gap: spacing.xs }}>
<Row style={{ justifyContent: "flex-end" }}>
{selected.size > 0 && (
<Button
label={`Ta bort valda (${selected.size})`}
@@ -141,37 +163,46 @@ export default function KitchenScreen() {
onPress={() => confirmDelete([...selected], "markeringen")}
/>
)}
<Button
label="Töm listan"
variant="ghost"
onPress={() =>
confirmDelete(
visible.map((i) => i.id),
locFilter ? locLabel(locFilter) : "hela lagret",
)
}
/>
{visible.length > 0 && (
<Button
label="Töm listan"
variant="ghost"
onPress={() => confirmDelete(visible.map((i) => i.id), emptyLabel)}
/>
)}
</Row>
</Row>
{visible.length === 0 && (
<EmptyState text="Inga varor att visa. Skanna eller lägg till varor." />
)}
{visible.map((item) => {
const isSel = selected.has(item.id);
return (
<Card key={item.id} onPress={() => toggle(item.id)}>
<Row style={{ justifyContent: "space-between", alignItems: "center" }}>
<Body>
{isSel ? "☑ " : "☐ "}
{item.displayName} · {formatQuantity(item.quantity, item.unit)}
</Body>
<Small>{locLabel(item.locationType)}</Small>
</Row>
{item.expiry.pastBestBefore && <Small> bäst före har passerat</Small>}
</Card>
);
})}
{groups.map((g) => (
<View key={g.type} style={{ gap: spacing.xs, marginTop: spacing.sm }}>
<Row style={{ justifyContent: "space-between" }}>
<Heading>{locLabel(g.type)}</Heading>
<Small>{g.items.length} st</Small>
</Row>
{g.items.map((item) => {
const isSel = selected.has(item.id);
return (
<Card
key={item.id}
onPress={() => toggle(item.id)}
style={isSel ? { borderColor: colors.primary, backgroundColor: colors.primarySoft } : undefined}
>
<Row style={{ justifyContent: "space-between" }}>
<Body>
{isSel ? "☑ " : "☐ "}
{item.displayName} · {formatQuantity(item.quantity, item.unit)}
</Body>
{item.expiry.pastBestBefore && <Small> bäst före</Small>}
</Row>
</Card>
);
})}
</View>
))}
</Screen>
);
}