App: die selbst draufgesetzten Posten fehlten in der Einkaufsliste

Die Einkaufsliste besteht aus zwei Haelften: gerechnete Bedarfe (Artikel
und Gruppen unter ihrem Mindestbestand, Bedarfe je Lagerort) und das,
was jemand selbst draufgesetzt hat - eigene Eintraege und die aus einem
Rezept uebernommenen Zutaten.

Die App kannte nur die erste Haelfte. Sie fragte /shopping-list,
/shopping-list/groups und /shopping-list/by-location ab - drei Routen,
die ausschliesslich rechnen. Die gespeicherten Eintraege liegen in
/shopping-list/entries und kommen gebuendelt ueber /shopping-list/all;
danach hat die App nie gefragt. Ergebnis: Man setzt im Web etwas auf die
Liste, nimmt das Telefon mit in den Laden, und es ist nicht da.

Jetzt holt die App die gebuendelte Route - ein Aufruf statt drei - und
zeigt die eigenen Posten in einem eigenen Abschnitt "Selbst
draufgesetzt", mit Herkunft ("fuer Pfannkuchen") und Menge. Antippen
hakt ab: Der Posten verschwindet sofort, geht das Speichern schief,
kommt er zurueck - lieber ein Ruecksprung als ein falsches Haekchen.

Dieselbe Blindstelle hatte die Dashboard-Karte der App; sie meldete
"alle Mindestbestaende sind gedeckt", waehrend Posten auf der Liste
standen. Auch sie nimmt jetzt die gebuendelte Route.

Dazu ein Hinweis im Docstring von GET /shopping-list: Diese Route ist
ein Drittel der Liste, wer "die Liste" will, nimmt /all. Die Aufteilung
hat in zwei Tagen zweimal jemanden glauben lassen, es sei nichts da -
beim zweiten Mal war es die App selbst.

Gegen den laufenden Server geprueft: Mit einem Eintrag "Backpapier"
liefert /shopping-list weiterhin [] und /shopping-list/all den Eintrag
samt art, name, menge_text, source, recipe_name und done - genau die
Felder, die das neue Swift-Modell liest.

Nicht kompiliert: kein Xcode auf dieser Maschine. Geprueft sind
Klammer- und Anfuehrungsbalance, dass die drei alten Routen niemand mehr
aufruft, und dass die benutzten Hilfsfunktionen (makeRequest, jsonBody,
send) wirklich so heissen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-03 07:24:58 +02:00
co-authored by Claude Opus 5
parent 892c52e49a
commit f04d80a330
5 changed files with 148 additions and 7 deletions
+8
View File
@@ -197,6 +197,14 @@ def einkaufsliste_artikel(db: Session) -> list[ShoppingItem]:
def shopping_list(
db: Session = Depends(get_db), _: User = Depends(get_current_user)
) -> list[ShoppingItem]:
"""Artikel unter ihrem „Ueberall"-Mindestbestand – NUR die gerechneten.
Diese Route ist ein Drittel der Einkaufsliste. Was jemand selbst
draufgesetzt hat (eigene Eintraege, uebernommene Rezeptzutaten) steht hier
nicht, ebenso wenig Gruppen und Bedarfe je Lagerort. Wer „die Liste" will,
nimmt ``/shopping-list/all`` – diese Aufteilung hat schon zweimal jemanden
glauben lassen, es sei nichts da.
"""
return einkaufsliste_artikel(db)
+16
View File
@@ -419,6 +419,22 @@ actor APIClient {
// MARK: - Listen
/// Die ganze Einkaufsliste - gerechnete Bedarfe UND eigene Eintraege.
///
/// Ein Aufruf statt drei, und vor allem der einzige, der die selbst
/// draufgesetzten Posten mitbringt.
func shoppingAll() async throws -> ShoppingListAll {
try await send(try makeRequest("/shopping-list/all"), as: ShoppingListAll.self)
}
/// Einen eigenen Eintrag abhaken oder wieder aufmachen.
@discardableResult
func setShoppingEntryDone(_ id: Int, done: Bool) async throws -> ShoppingEntry {
var request = try makeRequest("/shopping-list/entries/\(id)", method: "PATCH")
try jsonBody(&request, ["done": done])
return try await send(request, as: ShoppingEntry.self)
}
func shoppingList() async throws -> [ShoppingItem] {
try await send(try makeRequest("/shopping-list"), as: [ShoppingItem].self)
}
+22 -3
View File
@@ -226,18 +226,33 @@ struct ShoppingCard: View {
let stand: Int
@State private var items: [ShoppingItem] = []
/// Selbst draufgesetzte Posten. Ohne sie meldete die Karte, alles sei
/// gedeckt, waehrend auf der Liste fuenf Dinge standen.
@State private var eintraege: [ShoppingEntry] = []
@State private var fehler: String?
private var offene: [ShoppingEntry] { eintraege.filter { !$0.done } }
var body: some View {
Group {
if let fehler {
Text(fehler).font(.caption).foregroundStyle(.red)
} else if items.isEmpty {
} else if items.isEmpty && offene.isEmpty {
Text("Alle Mindestbestände sind gedeckt.")
.font(.caption).foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 6) {
ForEach(items.prefix(5)) { item in
// Eigene Eintraege zuerst: Sie stehen da, weil jemand sie
// bewusst draufgesetzt hat.
ForEach(offene.prefix(5)) { eintrag in
HStack {
Text(eintrag.name).font(.callout)
Spacer()
Text(eintrag.mengeText.isEmpty ? "selbst notiert" : eintrag.mengeText)
.font(.caption).foregroundStyle(.orange)
}
}
ForEach(items.prefix(max(0, 5 - offene.count))) { item in
HStack {
Text(item.name).font(.callout)
Spacer()
@@ -252,7 +267,11 @@ struct ShoppingCard: View {
}
.task(id: stand) {
do {
items = try await APIClient.shared.shoppingList()
// Die gebuendelte Route: Nur sie bringt auch die selbst
// draufgesetzten Posten mit.
let alles = try await APIClient.shared.shoppingAll()
items = alles.products
eintraege = alles.entries
fehler = nil
} catch {
self.fehler = error.localizedDescription
+49 -4
View File
@@ -51,14 +51,39 @@ struct ShoppingListView: View {
@State private var items: [ShoppingItem] = []
@State private var groups: [GroupShoppingItem] = []
@State private var byLocation: [LocationNeeds] = []
/// Selbst draufgesetzte Posten - von Hand oder aus einem Rezept uebernommen.
@State private var entries: [ShoppingEntry] = []
@State private var busy = true
@State private var error: String?
/// Abgehakte verschwinden: Die Liste ist zum Einkaufen da, nicht zum
/// Nachlesen. Das Aufraeumen ("Erledigte entfernen") macht die
/// Weboberflaeche.
private var offeneEintraege: [ShoppingEntry] { entries.filter { !$0.done } }
var body: some View {
List {
if let error {
Section { Text(error).foregroundStyle(.red).font(.callout) }
}
if !offeneEintraege.isEmpty {
Section("Selbst draufgesetzt") {
ForEach(offeneEintraege) { eintrag in
Button {
Task { await hake(eintrag) }
} label: {
ListRow(
systemImage: eintrag.istNotiz ? "square.and.pencil" : "shippingbox",
title: eintrag.name,
subtitle: eintrag.untertitel
) {
if eintrag.istGruppe { GroupBadge() }
}
}
.buttonStyle(.plain)
}
}
}
if !items.isEmpty {
Section("Artikel unter Mindestbestand") {
ForEach(items) { item in
@@ -103,7 +128,8 @@ struct ShoppingListView: View {
}
}
}
if items.isEmpty && groups.isEmpty && byLocation.isEmpty && !busy {
if items.isEmpty && groups.isEmpty && byLocation.isEmpty
&& offeneEintraege.isEmpty && !busy {
Text("Nichts einzukaufen – alle Mindestbestände sind gedeckt.")
.foregroundStyle(.secondary)
}
@@ -114,13 +140,32 @@ struct ShoppingListView: View {
.task { await load() }
}
/// Abhaken. Der Posten verschwindet sofort; geht das Speichern schief,
/// kommt er zurueck - lieber ein Ruecksprung als ein falsches Haekchen.
private func hake(_ eintrag: ShoppingEntry) async {
let vorher = entries
entries.removeAll { $0.id == eintrag.id }
do {
try await APIClient.shared.setShoppingEntryDone(eintrag.id, done: true)
} catch {
entries = vorher
self.error = error.localizedDescription
}
}
private func load() async {
busy = true
defer { busy = false }
do {
items = try await APIClient.shared.shoppingList()
groups = try await APIClient.shared.shoppingGroups()
byLocation = try await APIClient.shared.shoppingByLocation()
// EIN Aufruf statt drei - und der einzige, der die selbst
// draufgesetzten Posten mitbringt. Die drei Einzelrouten liefern
// nur die gerechneten Bedarfe; wer nur sie abfragt, sieht die
// eigenen Eintraege ueberhaupt nicht.
let alles = try await APIClient.shared.shoppingAll()
items = alles.products
groups = alles.groups
byLocation = alles.byLocation
entries = alles.entries
error = nil
} catch {
self.error = error.localizedDescription
+53
View File
@@ -482,6 +482,59 @@ struct NewProductRequest: Codable {
// MARK: - Listen (Einkaufsliste, bald ablaufend)
/// Ein Posten, den jemand selbst auf die Einkaufsliste gesetzt hat - von Hand
/// oder uebernommen aus einem Rezept.
///
/// Die gerechneten Posten (Artikel und Gruppen unter ihrem Mindestbestand)
/// entstehen bei jedem Aufruf neu; diese hier liegen in der Datenbank und
/// bleiben, bis sie abgehakt sind. Beides zusammen ergibt erst die Liste.
struct ShoppingEntry: Codable, Identifiable {
let id: Int
/// "product" | "group" | "text"
let art: String
let name: String
/// "2 Packungen" bzw. "150 g" - fertig formatiert vom Server, damit App und
/// Weboberflaeche dieselbe Sprache sprechen.
let mengeText: String
let source: String?
let recipeName: String?
let done: Bool
var istNotiz: Bool { art == "text" }
var istGruppe: Bool { art == "group" }
/// Zeile unter dem Namen: woher der Posten kommt und wie viel fehlt.
var untertitel: String {
[recipeName.map { "für \($0)" }, mengeText.isEmpty ? nil : mengeText]
.compactMap { $0 }
.joined(separator: " · ")
}
enum CodingKeys: String, CodingKey {
case id, art, name, source, done
case mengeText = "menge_text"
case recipeName = "recipe_name"
}
}
/// Die komplette Einkaufsliste in einer Antwort: gerechnete Bedarfe UND
/// gespeicherte Eintraege. Die drei Einzelrouten liefern nur die gerechnete
/// Seite - wer nur sie abfragt, sieht die selbst draufgesetzten Posten nicht.
struct ShoppingListAll: Codable {
let products: [ShoppingItem]
let groups: [GroupShoppingItem]
let byLocation: [LocationNeeds]
let entries: [ShoppingEntry]
/// Kennungen der abgehakten GERECHNETEN Posten. Die App zeigt sie noch
/// nicht an, liest sie aber mit, damit das Feld nicht ueberrascht.
let checked: [String]
enum CodingKeys: String, CodingKey {
case products, groups, entries, checked
case byLocation = "by_location"
}
}
struct ShoppingItem: Codable, Identifiable {
let productId: Int
let name: String