diff --git a/CHANGELOG.md b/CHANGELOG.md index af59e21..abc5ab0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +## [0.24.1] - 2026-10-31 + +### Fixed +- Einkaufslisten offline: Neue Einträge ohne Verbindung meldeten „keine Verbindung zum Server“. Jetzt erscheinen sie sofort in der Liste (mit kleinem Wolken-Symbol), bleiben auch nach einem Neuladen sichtbar und werden gesendet, sobald die Verbindung zurück ist. Abhaken, Löschen oder Menge ändern, bevor sie gesendet wurden, wirkt direkt auf den wartenden Eintrag. + +### Changed +- Einkaufslisten: Das Feld für neue Einträge ist jetzt als Eingabefeld erkennbar (Rahmen, Plus im Feld) und nimmt den meisten Platz ein; Menge und „Hinzufügen“ sind gleich hoch daneben bzw. am Handy darunter. + ## [0.24.0] - 2026-10-31 ### Added @@ -492,7 +500,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V - Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken. - Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung. -[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.24.0...dev +[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.24.1...dev +[0.24.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.24.0...v0.24.1 [0.24.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.23.0...v0.24.0 [0.23.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.22.2...v0.23.0 [0.22.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.22.1...v0.22.2 diff --git a/Doku/plans/registrierung-und-login.md b/Doku/plans/registrierung-und-login.md index 052ab22..680ff34 100644 --- a/Doku/plans/registrierung-und-login.md +++ b/Doku/plans/registrierung-und-login.md @@ -1,6 +1,6 @@ # Plan: Registrierung und Anmeldung (Entwurf) -Stand 2026-10-31. **Umgesetzt in v0.23.0:** Abschnitte 1 (Registrierungsmodus und Freigabe), 2 (Umgebungsvariablen) und 3 (Einladungslinks, per E-Mail einladen, mehrfach verwendbare Links). E-Mail-Bestätigung per Link in v0.24.0. Offen: OpenID Connect. Ziel: ein Zwischending zwischen „jeder darf sich registrieren“ und „für jede Person einen Einladungscode erzeugen“, dazu bequemere Anmeldung. +Stand 2026-10-31. **Umgesetzt in v0.23.0:** Abschnitte 1 (Registrierungsmodus und Freigabe), 2 (Umgebungsvariablen) und 3 (Einladungslinks, per E-Mail einladen, mehrfach verwendbare Links). E-Mail-Bestätigung per Link in v0.24.0. OpenID Connect ist zurückgestellt (kein Bedarf), vermerkt in `TODO.md`. Ziel: ein Zwischending zwischen „jeder darf sich registrieren“ und „für jede Person einen Einladungscode erzeugen“, dazu bequemere Anmeldung. ## Heute @@ -66,7 +66,7 @@ Heute: Der Admin erzeugt einen Code (optional an eine E-Mail-Adresse und Organis - Mehrfach-Codes können nicht an eine einzelne E-Mail gebunden werden, wohl aber an eine Organisation und an die erlaubten E-Mail-Endungen (Abschnitt 1). - Technisch: Spalten `max_uses` und `use_count` an `user_invites`; das bestehende „Code reservieren → Konto anlegen → als benutzt markieren“ zählt dann hoch statt abzuschließen (weiter ohne Doppelverwendung bei gleichzeitigen Registrierungen). -## 4. Anmelden mit anderen Konten +## 4. Anmelden mit anderen Konten (zurückgestellt) ### OpenID Connect (statt „nur Google“) diff --git a/README.md b/README.md index 60a1d7a..b8bd84e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart) [![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir) -[![Version](https://img.shields.io/badge/Version-v0.24.0-16a34a?style=flat-square)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/Version-v0.24.1-16a34a?style=flat-square)](CHANGELOG.md) **MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 99f9442..a25495c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mindsift-frontend", - "version": "0.24.0", + "version": "0.24.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mindsift-frontend", - "version": "0.24.0", + "version": "0.24.1", "license": "Unlicense", "dependencies": { "@fontsource-variable/inter": "5.3.0", diff --git a/frontend/package.json b/frontend/package.json index 586169b..448da46 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mindsift-frontend", "private": true, - "version": "0.24.0", + "version": "0.24.1", "license": "Unlicense", "type": "module", "scripts": { diff --git a/frontend/src/features/lists/SharedListsView.tsx b/frontend/src/features/lists/SharedListsView.tsx index 6808609..1952a39 100644 --- a/frontend/src/features/lists/SharedListsView.tsx +++ b/frontend/src/features/lists/SharedListsView.tsx @@ -7,10 +7,12 @@ import { Sparkles, X, ShoppingCart, + CloudOff, } from 'lucide-react'; import { ListItem, SharedList, Team, User, WSMessage } from '../../lib/types'; import { api, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api'; import { readViewSession, writeViewSession } from '../../lib/viewSession'; +import { OFFLINE_ITEM_PREFIX, pendingListItems } from '../../lib/offline'; import { UnifiedShareModal } from '../../components/UnifiedShareModal'; import { AvatarStack, AvatarPerson } from '../../components/AvatarStack'; import { CountBadge } from '../../components/CountBadge'; @@ -138,9 +140,14 @@ export const SharedListsView: React.FC = ({ onRefreshGloba // fallback } } + // Entries added offline and not sent yet stay visible (also after a reload without connection) + const pending = pendingListItems(); const deduplicated = all.map((l) => ({ ...l, - items: deduplicateItems(l.items || []), + items: deduplicateItems([ + ...pending.filter((p) => p.listId === l.id).map((p) => ({ ...p, isCompleted: false, sortOrder: 0 }) as ListItem), + ...(l.items || []), + ]), })); setLists(deduplicated); if (initialListId && deduplicated.some((l) => l.id === initialListId)) { @@ -268,6 +275,22 @@ export const SharedListsView: React.FC = ({ onRefreshGloba }; }, [activeList?.id]); + // Back online: once the outbox has sent the entries added offline, show the server's version + const listsRef = useRef(lists); + listsRef.current = lists; + const loadListsRef = useRef(loadLists); + loadListsRef.current = loadLists; + useEffect(() => { + const onOutbox = () => { + const shown = listsRef.current.some((l) => l.items.some((i) => i.id.startsWith(OFFLINE_ITEM_PREFIX))); + const queued = new Set(pendingListItems().map((p) => p.id)); + const sent = listsRef.current.some((l) => l.items.some((i) => i.id.startsWith(OFFLINE_ITEM_PREFIX) && !queued.has(i.id))); + if (shown && sent) void loadListsRef.current(); + }; + window.addEventListener('mindsift:outbox-changed', onOutbox); + return () => window.removeEventListener('mindsift:outbox-changed', onOutbox); + }, []); + useEffect(() => { const handleGlobalWs = (e: any) => { const msg = e.detail; @@ -1087,10 +1110,11 @@ export const SharedListsView: React.FC = ({ onRefreshGloba {/* Quick-Add Form */}
-
- + {/* The item is the main field: a real input like the quantity next to it, with the + inside */} +
+ = ({ onRefreshGloba onBlur={() => { setTimeout(() => setShowSuggestions(false), 200); }} - placeholder={`Eintrag zu "${activeList.title}" hinzufügen (z. B. Hafermilch #Kühlregal)…`} + placeholder={`Was soll auf „${activeList.title}“? (z. B. Hafermilch #Kühlregal)`} // 16px on phones: iOS zooms into smaller fields and the layout jumps under the keyboard - className="flex-1 bg-transparent py-1.5 sm:py-1 text-base sm:text-sm text-main placeholder-faint focus:outline-none" + className="w-full h-11 sm:h-9 pl-9 pr-3 input-field text-base sm:text-sm" /> {/* Hashtag Autocomplete Suggestions Dropdown */} @@ -1152,19 +1176,20 @@ export const SharedListsView: React.FC = ({ onRefreshGloba
)}
-
+
setNewItemQty(e.target.value.replace(/[^0-9.,xX]/g, ''))} - placeholder="Menge (z.B. 2)" - className="flex-1 sm:flex-none sm:w-28 h-11 sm:h-7 px-2.5 sm:px-2 input-field text-base sm:text-xs" + placeholder="Menge" + aria-label="Menge (optional)" + className="w-24 sm:w-20 h-11 sm:h-9 px-2.5 input-field text-base sm:text-sm" /> @@ -1268,6 +1293,11 @@ export const SharedListsView: React.FC = ({ onRefreshGloba {displayContent} + {item.id.startsWith(OFFLINE_ITEM_PREFIX) && ( + + + + )} {editingQtyItemId === item.id ? (
e.stopPropagation()}> diff --git a/frontend/src/lib/__tests__/offline.test.ts b/frontend/src/lib/__tests__/offline.test.ts index e62f459..2a4ae18 100644 --- a/frontend/src/lib/__tests__/offline.test.ts +++ b/frontend/src/lib/__tests__/offline.test.ts @@ -47,3 +47,28 @@ describe('outbox', () => { expect(isCacheablePath('/api/v1/export/markdown')).toBe(false); }); }); + +describe('list items added offline', () => { + beforeEach(() => localStorage.clear()); + + it('show up as stand-ins and follow edits until they are sent', async () => { + const { pendingListItems, updateQueued, dropQueued, OFFLINE_ITEM_PREFIX } = await import('../offline'); + const milk = enqueue('POST', '/api/v1/lists/lst_1/items', { content: 'Hafermilch', category: 'Kühlregal', quantity: '' }); + const bread = enqueue('POST', '/api/v1/lists/lst_2/items', { content: 'Brot', category: '', quantity: '1' }); + enqueue('POST', '/api/v1/inbox', { content: 'kein Listeneintrag' }); + + let items = pendingListItems(); + expect(items.map((i) => [i.listId, i.content])).toEqual([ + ['lst_1', 'Hafermilch'], + ['lst_2', 'Brot'], + ]); + expect(items[0].id).toBe(OFFLINE_ITEM_PREFIX + milk.id); + + updateQueued(milk.id, { quantity: '2' }); + dropQueued(bread.id); + items = pendingListItems(); + expect(items).toHaveLength(1); + expect(items[0].quantity).toBe('2'); + expect(getOutbox()).toHaveLength(2); // the inbox entry stays + }); +}); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 8966f86..5b3675f 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -52,6 +52,9 @@ import { clearOfflineData, enqueue, flushOutbox, + dropQueued, + updateQueued, + OFFLINE_ITEM_PREFIX, } from './offline'; const TOKEN_STORAGE_KEY = 'mindsift_jwt_token'; @@ -961,13 +964,32 @@ export const api = { }, async createListItem(listId: string, content: string, category = '', quantity = ''): Promise { - return request(`/api/v1/lists/${listId}/items`, { - method: 'POST', - body: JSON.stringify({ content, category, quantity }), - }); + const path = `/api/v1/lists/${listId}/items`; + try { + return await request(path, { method: 'POST', body: JSON.stringify({ content, category, quantity }) }); + } catch (err) { + if (!(err instanceof OfflineError)) throw err; + // Kept in the outbox and sent when the connection is back; until then a stand-in in the list + const entry = enqueue('POST', path, { content, category, quantity }); + return { + id: OFFLINE_ITEM_PREFIX + entry.id, + listId, + content, + category, + quantity, + isCompleted: false, + sortOrder: 0, + createdAt: entry.createdAt, + } as ListItem; + } }, async toggleListItem(listId: string, itemId: string, isCompleted?: boolean): Promise { + // Added offline and checked off before it was sent: nothing left to send + if (itemId.startsWith(OFFLINE_ITEM_PREFIX) && isCompleted) { + dropQueued(itemId.slice(OFFLINE_ITEM_PREFIX.length)); + return { id: itemId, listId, isCompleted: true } as ListItem; + } try { return await request(`/api/v1/lists/${listId}/items/${itemId}`, { method: 'PATCH', @@ -985,6 +1007,10 @@ export const api = { itemId: string, payload: { content?: string; category?: string; quantity?: string } ): Promise { + if (itemId.startsWith(OFFLINE_ITEM_PREFIX)) { + updateQueued(itemId.slice(OFFLINE_ITEM_PREFIX.length), payload); + return { id: itemId, listId, ...payload } as ListItem; + } return request(`/api/v1/lists/${listId}/items/${itemId}`, { method: 'PUT', body: JSON.stringify(payload), @@ -992,6 +1018,10 @@ export const api = { }, async deleteListItem(listId: string, itemId: string): Promise { + if (itemId.startsWith(OFFLINE_ITEM_PREFIX)) { + dropQueued(itemId.slice(OFFLINE_ITEM_PREFIX.length)); + return; + } await request(`/api/v1/lists/${listId}/items/${itemId}`, { method: 'DELETE' }); }, diff --git a/frontend/src/lib/offline.ts b/frontend/src/lib/offline.ts index 4ad442d..41f476b 100644 --- a/frontend/src/lib/offline.ts +++ b/frontend/src/lib/offline.ts @@ -154,6 +154,43 @@ export function enqueue(method: OutboxEntry['method'], path: string, body: Recor export const outboxCount = () => getOutbox().length; +/** Changes the body of a queued entry (e.g. the quantity of a list item added offline). */ +export function updateQueued(entryId: string, body: Record): boolean { + const entries = getOutbox(); + const entry = entries.find((e) => e.id === entryId); + if (!entry) return false; + entry.body = { ...entry.body, ...body }; + saveOutbox(entries); + return true; +} + +/** Removes a queued entry that is no longer wanted (e.g. a list item added and deleted while offline). */ +export function dropQueued(entryId: string): boolean { + const entries = getOutbox(); + const rest = entries.filter((e) => e.id !== entryId); + if (rest.length === entries.length) return false; + saveOutbox(rest); + return true; +} + +// List items added offline: a stand-in id until the server knows them, derived from the outbox entry +export const OFFLINE_ITEM_PREFIX = 'itm_offline_'; +const LIST_ITEMS_PATH = /^\/api\/v1\/lists\/([^/]+)\/items$/; + +/** List items still waiting in the outbox, as stand-ins for the list view (also after a reload). */ +export function pendingListItems(): { listId: string; id: string; content: string; category: string; quantity: string; createdAt: string }[] { + return getOutbox() + .filter((e) => e.method === 'POST' && LIST_ITEMS_PATH.test(e.path)) + .map((e) => ({ + listId: (e.path.match(LIST_ITEMS_PATH) as RegExpMatchArray)[1], + id: OFFLINE_ITEM_PREFIX + e.id, + content: String(e.body.content || ''), + category: String(e.body.category || ''), + quantity: String(e.body.quantity || ''), + createdAt: e.createdAt, + })); +} + let flushing: Promise | null = null; /**