fix(lists): add items offline via the outbox; recognisable input field
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m34s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m34s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ab97638361
commit
28ebbe7263
9 files changed
+152
-21
No files matched your search
+10
-1
@@ -6,6 +6,14 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.24.0] - 2026-10-31
|
||||||
|
|
||||||
### Added
|
### 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.
|
- 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.
|
- 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.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.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
|
[0.22.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.22.1...v0.22.2
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Plan: Registrierung und Anmeldung (Entwurf)
|
# 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
|
## 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).
|
- 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).
|
- 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“)
|
### OpenID Connect (statt „nur Google“)
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
[](#)
|
[](#)
|
||||||
[](#-schnellstart)
|
[](#-schnellstart)
|
||||||
[](#-deine-daten-gehören-dir)
|
[](#-deine-daten-gehören-dir)
|
||||||
[](CHANGELOG.md)
|
[](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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.24.0",
|
"version": "0.24.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.24.0",
|
"version": "0.24.1",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/inter": "5.3.0",
|
"@fontsource-variable/inter": "5.3.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.24.0",
|
"version": "0.24.1",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
X,
|
X,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
|
CloudOff,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { ListItem, SharedList, Team, User, WSMessage } from '../../lib/types';
|
import { ListItem, SharedList, Team, User, WSMessage } from '../../lib/types';
|
||||||
import { api, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api';
|
import { api, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api';
|
||||||
import { readViewSession, writeViewSession } from '../../lib/viewSession';
|
import { readViewSession, writeViewSession } from '../../lib/viewSession';
|
||||||
|
import { OFFLINE_ITEM_PREFIX, pendingListItems } from '../../lib/offline';
|
||||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||||
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
||||||
import { CountBadge } from '../../components/CountBadge';
|
import { CountBadge } from '../../components/CountBadge';
|
||||||
@@ -138,9 +140,14 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
// fallback
|
// fallback
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Entries added offline and not sent yet stay visible (also after a reload without connection)
|
||||||
|
const pending = pendingListItems();
|
||||||
const deduplicated = all.map((l) => ({
|
const deduplicated = all.map((l) => ({
|
||||||
...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);
|
setLists(deduplicated);
|
||||||
if (initialListId && deduplicated.some((l) => l.id === initialListId)) {
|
if (initialListId && deduplicated.some((l) => l.id === initialListId)) {
|
||||||
@@ -268,6 +275,22 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
};
|
};
|
||||||
}, [activeList?.id]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const handleGlobalWs = (e: any) => {
|
const handleGlobalWs = (e: any) => {
|
||||||
const msg = e.detail;
|
const msg = e.detail;
|
||||||
@@ -1087,10 +1110,11 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
{/* Quick-Add Form */}
|
{/* Quick-Add Form */}
|
||||||
<form
|
<form
|
||||||
onSubmit={handleAddItem}
|
onSubmit={handleAddItem}
|
||||||
className="flex flex-col sm:flex-row items-center gap-2 panel-surface p-2 sm:p-1.5 relative"
|
className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 panel-surface p-2 relative"
|
||||||
>
|
>
|
||||||
<div className="relative flex items-center gap-2 flex-1 w-full px-1.5">
|
{/* The item is the main field: a real input like the quantity next to it, with the + inside */}
|
||||||
<Plus className="w-4 h-4 text-accent flex-shrink-0" />
|
<div className="relative flex-1 w-full min-w-0">
|
||||||
|
<Plus className="w-4 h-4 text-accent absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" />
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
@@ -1112,9 +1136,9 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
setTimeout(() => setShowSuggestions(false), 200);
|
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
|
// 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 */}
|
{/* Hashtag Autocomplete Suggestions Dropdown */}
|
||||||
@@ -1152,19 +1176,20 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 w-full sm:w-auto justify-end px-1 sm:px-0">
|
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
value={newItemQty}
|
value={newItemQty}
|
||||||
onChange={(e) => setNewItemQty(e.target.value.replace(/[^0-9.,xX]/g, ''))}
|
onChange={(e) => setNewItemQty(e.target.value.replace(/[^0-9.,xX]/g, ''))}
|
||||||
placeholder="Menge (z.B. 2)"
|
placeholder="Menge"
|
||||||
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"
|
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"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmittingItem || !newItemText.trim()}
|
disabled={isSubmittingItem || !newItemText.trim()}
|
||||||
className="btn-primary h-9 sm:h-7 px-3.5 sm:px-3 text-xs disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-1 flex-shrink-0"
|
className="btn-primary flex-1 sm:flex-none h-11 sm:h-9 px-4 text-sm sm:text-xs disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-1 flex-shrink-0"
|
||||||
>
|
>
|
||||||
{isSubmittingItem ? 'Wird hinzugefügt…' : 'Hinzufügen'}
|
{isSubmittingItem ? 'Wird hinzugefügt…' : 'Hinzufügen'}
|
||||||
</button>
|
</button>
|
||||||
@@ -1268,6 +1293,11 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
<span className="text-sm font-medium text-main truncate">
|
<span className="text-sm font-medium text-main truncate">
|
||||||
{displayContent}
|
{displayContent}
|
||||||
</span>
|
</span>
|
||||||
|
{item.id.startsWith(OFFLINE_ITEM_PREFIX) && (
|
||||||
|
<span title="Offline hinzugefügt – wird gesendet, sobald die Verbindung zurück ist">
|
||||||
|
<CloudOff className="w-3.5 h-3.5 text-faint shrink-0" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{editingQtyItemId === item.id ? (
|
{editingQtyItemId === item.id ? (
|
||||||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||||
|
|||||||
@@ -47,3 +47,28 @@ describe('outbox', () => {
|
|||||||
expect(isCacheablePath('/api/v1/export/markdown')).toBe(false);
|
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
|
||||||
|
});
|
||||||
|
});
|
||||||
+34
-4
@@ -52,6 +52,9 @@ import {
|
|||||||
clearOfflineData,
|
clearOfflineData,
|
||||||
enqueue,
|
enqueue,
|
||||||
flushOutbox,
|
flushOutbox,
|
||||||
|
dropQueued,
|
||||||
|
updateQueued,
|
||||||
|
OFFLINE_ITEM_PREFIX,
|
||||||
} from './offline';
|
} from './offline';
|
||||||
|
|
||||||
const TOKEN_STORAGE_KEY = 'mindsift_jwt_token';
|
const TOKEN_STORAGE_KEY = 'mindsift_jwt_token';
|
||||||
@@ -961,13 +964,32 @@ export const api = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async createListItem(listId: string, content: string, category = '', quantity = ''): Promise<ListItem> {
|
async createListItem(listId: string, content: string, category = '', quantity = ''): Promise<ListItem> {
|
||||||
return request<ListItem>(`/api/v1/lists/${listId}/items`, {
|
const path = `/api/v1/lists/${listId}/items`;
|
||||||
method: 'POST',
|
try {
|
||||||
body: JSON.stringify({ content, category, quantity }),
|
return await request<ListItem>(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<ListItem> {
|
async toggleListItem(listId: string, itemId: string, isCompleted?: boolean): Promise<ListItem> {
|
||||||
|
// 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 {
|
try {
|
||||||
return await request<ListItem>(`/api/v1/lists/${listId}/items/${itemId}`, {
|
return await request<ListItem>(`/api/v1/lists/${listId}/items/${itemId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@@ -985,6 +1007,10 @@ export const api = {
|
|||||||
itemId: string,
|
itemId: string,
|
||||||
payload: { content?: string; category?: string; quantity?: string }
|
payload: { content?: string; category?: string; quantity?: string }
|
||||||
): Promise<ListItem> {
|
): Promise<ListItem> {
|
||||||
|
if (itemId.startsWith(OFFLINE_ITEM_PREFIX)) {
|
||||||
|
updateQueued(itemId.slice(OFFLINE_ITEM_PREFIX.length), payload);
|
||||||
|
return { id: itemId, listId, ...payload } as ListItem;
|
||||||
|
}
|
||||||
return request<ListItem>(`/api/v1/lists/${listId}/items/${itemId}`, {
|
return request<ListItem>(`/api/v1/lists/${listId}/items/${itemId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
@@ -992,6 +1018,10 @@ export const api = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async deleteListItem(listId: string, itemId: string): Promise<void> {
|
async deleteListItem(listId: string, itemId: string): Promise<void> {
|
||||||
|
if (itemId.startsWith(OFFLINE_ITEM_PREFIX)) {
|
||||||
|
dropQueued(itemId.slice(OFFLINE_ITEM_PREFIX.length));
|
||||||
|
return;
|
||||||
|
}
|
||||||
await request(`/api/v1/lists/${listId}/items/${itemId}`, { method: 'DELETE' });
|
await request(`/api/v1/lists/${listId}/items/${itemId}`, { method: 'DELETE' });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -154,6 +154,43 @@ export function enqueue(method: OutboxEntry['method'], path: string, body: Recor
|
|||||||
|
|
||||||
export const outboxCount = () => getOutbox().length;
|
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<string, unknown>): 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<number> | null = null;
|
let flushing: Promise<number> | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in new issue
Block a user