feat(lists): replace category buttons with dynamic hashtag categories and group open items by hashtag
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s

This commit is contained in:
sandrodach committed 2026-10-25 11:09:47 +01:00
1 parent 7bc5fbe001
commit 51244c736c
2 files changed
+187 -79

No files matched your search

+169 -78
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { import {
Plus, Plus,
CheckCircle2, CheckCircle2,
@@ -20,7 +20,50 @@ interface SharedListsViewProps {
currentUser?: User | null; currentUser?: User | null;
} }
const CATEGORIES = ['Obst & Gemüse', 'Kühlregal', 'Vorrat & Konserven', 'Backwaren', 'Getränke', 'Drogerie', 'Sonstiges']; export function parseListItemInput(rawText: string, defaultQty?: string): {
content: string;
quantity: string;
category?: string;
} {
let text = rawText.trim();
const hashtagRegex = /#([a-zA-Z0-9äöüÄÖÜß_-]+)/g;
const matches = Array.from(text.matchAll(hashtagRegex));
let category: string | undefined = undefined;
if (matches.length > 0) {
category = matches[0][1];
text = text.replace(hashtagRegex, '').replace(/\s+/g, ' ').trim();
}
if (!text && matches.length > 0) {
text = matches[0][1];
}
return {
content: text,
quantity: defaultQty?.trim() || '',
category,
};
}
export function getItemCategory(item: ListItem): string {
if (item.category && item.category.trim()) {
return item.category.trim().replace(/^#/, '');
}
const match = item.content.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/);
if (match) {
return match[1];
}
return '';
}
export function getItemDisplayContent(item: ListItem): string {
if (item.content.includes('#')) {
const cleaned = item.content.replace(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g, '').replace(/\s+/g, ' ').trim();
if (cleaned) return cleaned;
}
return item.content;
}
export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGlobal, currentUser }) => { export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGlobal, currentUser }) => {
const [spaces, setSpaces] = useState<Space[]>([]); const [spaces, setSpaces] = useState<Space[]>([]);
@@ -30,7 +73,6 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
const [newItemText, setNewItemText] = useState(''); const [newItemText, setNewItemText] = useState('');
const [newItemQty, setNewItemQty] = useState(''); const [newItemQty, setNewItemQty] = useState('');
const [newItemCategory, setNewItemCategory] = useState('');
const [wsConnected, setWsConnected] = useState(false); const [wsConnected, setWsConnected] = useState(false);
const [copiedInvite, setCopiedInvite] = useState(false); const [copiedInvite, setCopiedInvite] = useState(false);
@@ -176,15 +218,12 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
if (!activeList || !newItemText.trim()) return; if (!activeList || !newItemText.trim()) return;
triggerHaptic(15); triggerHaptic(15);
const text = newItemText.trim(); const parsed = parseListItemInput(newItemText, newItemQty);
const qty = newItemQty.trim();
const cat = newItemCategory || undefined;
setNewItemText(''); setNewItemText('');
setNewItemQty(''); setNewItemQty('');
try { try {
const item = await api.createListItem(activeList.id, text, qty, cat); const item = await api.createListItem(activeList.id, parsed.content, parsed.category, parsed.quantity);
setLists((prev) => setLists((prev) =>
prev.map((l) => (l.id === activeList.id ? { ...l, items: [item, ...l.items] } : l)) prev.map((l) => (l.id === activeList.id ? { ...l, items: [item, ...l.items] } : l))
); );
@@ -352,6 +391,46 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
const openItems = activeList?.items.filter((i) => !i.isCompleted) || []; const openItems = activeList?.items.filter((i) => !i.isCompleted) || [];
const completedItems = activeList?.items.filter((i) => i.isCompleted) || []; const completedItems = activeList?.items.filter((i) => i.isCompleted) || [];
const groupedOpenItems = useMemo(() => {
const groupsMap = new Map<string, ListItem[]>();
const uncategorized: ListItem[] = [];
for (const item of openItems) {
const cat = getItemCategory(item);
if (!cat) {
uncategorized.push(item);
} else {
const list = groupsMap.get(cat) || [];
list.push(item);
groupsMap.set(cat, list);
}
}
const sortedCategories = Array.from(groupsMap.keys()).sort((a, b) =>
a.localeCompare(b, 'de', { sensitivity: 'base' })
);
const result: { category: string; label: string; items: ListItem[] }[] = [];
for (const cat of sortedCategories) {
result.push({
category: cat,
label: `#${cat}`,
items: groupsMap.get(cat)!,
});
}
if (uncategorized.length > 0) {
result.push({
category: '',
label: result.length > 0 ? 'Allgemein' : '',
items: uncategorized,
});
}
return result;
}, [openItems]);
return ( return (
<div className="w-full space-y-3"> <div className="w-full space-y-3">
{/* Top Space Bar & Realtime Indicator */} {/* Top Space Bar & Realtime Indicator */}
@@ -542,14 +621,14 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
{/* Active Shared List Card */} {/* Active Shared List Card */}
{activeList ? ( {activeList ? (
<div className="p-3 panel-surface"> <div className="p-3 panel-surface">
{/* Quick-Add Pill Form */} {/* Quick-Add Form */}
<form onSubmit={handleAddItem} className="space-y-2 mb-3"> <form onSubmit={handleAddItem} className="space-y-2 mb-3">
<div className="flex flex-col sm:flex-row gap-2"> <div className="flex flex-col sm:flex-row gap-2">
<input <input
type="text" type="text"
value={newItemText} value={newItemText}
onChange={(e) => setNewItemText(e.target.value)} onChange={(e) => setNewItemText(e.target.value)}
placeholder={`Eintrag zu "${activeList.title}" hinzufügen (z. B. Hafermilch)…`} placeholder={`Eintrag zu "${activeList.title}" hinzufügen (z. B. Hafermilch #Kühlregal oder #Kaffee)…`}
className="flex-1 h-9 px-3 input-field text-sm" className="flex-1 h-9 px-3 input-field text-sm"
/> />
<div className="flex gap-2"> <div className="flex gap-2">
@@ -570,28 +649,9 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
</button> </button>
</div> </div>
</div> </div>
{/* Category Quick-Select Pills */}
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs text-muted mr-1">Kategorie:</span>
{CATEGORIES.map((cat) => (
<button
key={cat}
type="button"
onClick={() => setNewItemCategory((prev) => (prev === cat ? '' : cat))}
className={`px-2.5 py-0.5 text-xs transition-colors border rounded-pill ${
newItemCategory === cat
? 'bg-accent text-on-accent border-accent font-medium'
: 'bg-canvas text-muted hover:text-main border-border'
}`}
>
{cat}
</button>
))}
</div>
</form> </form>
{/* Open Items */} {/* Open Items Grouped by Hashtags / Categories */}
<div className="border border-border divide-y divide-border rounded-card overflow-hidden"> <div className="border border-border divide-y divide-border rounded-card overflow-hidden">
{openItems.length === 0 ? ( {openItems.length === 0 ? (
<div className="py-6 text-center text-muted text-sm flex flex-col items-center gap-1.5"> <div className="py-6 text-center text-muted text-sm flex flex-col items-center gap-1.5">
@@ -599,39 +659,60 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
<span>Alles erledigt. Keine offenen Punkte auf dieser Liste.</span> <span>Alles erledigt. Keine offenen Punkte auf dieser Liste.</span>
</div> </div>
) : ( ) : (
openItems.map((item) => ( groupedOpenItems.map((group) => (
<div <div key={group.category || 'uncategorized'} className="divide-y divide-border">
key={item.id} {/* Category Group Header (only when categories are in use) */}
onClick={() => handleToggleItem(item)} {group.label && (
className="px-3 py-2 bg-canvas hover:bg-panel-elevated flex items-center justify-between gap-3 cursor-pointer transition-colors group" <div className="px-3 py-1.5 bg-surface-2/70 text-xs font-semibold text-accent flex items-center justify-between">
> <span className="flex items-center gap-1">
<div className="flex items-center gap-2.5 min-w-0"> <span>{group.label}</span>
<Circle className="w-4 h-4 text-faint group-hover:text-accent flex-shrink-0 transition-colors" />
<span className="text-sm font-normal text-main truncate">
{item.content}
</span>
{item.quantity && (
<span className="badge-accent">
{item.quantity}
</span> </span>
)} <span className="text-[10px] text-muted font-normal">
{item.category && ( {group.items.length} {group.items.length === 1 ? 'Eintrag' : 'Einträge'}
<span className="hidden sm:inline px-2 py-0.2 badge-neutral text-xs">
{item.category}
</span> </span>
)} </div>
</div> )}
<button {group.items.map((item) => {
type="button" const cat = getItemCategory(item);
onClick={(e) => { const displayContent = getItemDisplayContent(item);
e.stopPropagation();
handleDeleteItem(item.id); return (
}} <div
className="btn-icon btn-icon-danger w-6 h-6" key={item.id}
> onClick={() => handleToggleItem(item)}
<Trash2 className="w-3 h-3" /> className="px-3 py-2 bg-canvas hover:bg-panel-elevated flex items-center justify-between gap-3 cursor-pointer transition-colors group"
</button> >
<div className="flex items-center gap-2.5 min-w-0">
<Circle className="w-4 h-4 text-faint group-hover:text-accent flex-shrink-0 transition-colors" />
<span className="text-sm font-normal text-main truncate">
{displayContent}
</span>
{item.quantity && (
<span className="badge-accent">
{item.quantity}
</span>
)}
{cat && (
<span className="px-2 py-0.5 text-xs font-medium text-accent bg-accent/10 border border-accent/25 rounded-full">
#{cat}
</span>
)}
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleDeleteItem(item.id);
}}
className="btn-icon btn-icon-danger w-6 h-6"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
);
})}
</div> </div>
)) ))
)} )}
@@ -653,26 +734,36 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
</div> </div>
<div className="border border-border divide-y divide-border opacity-75 rounded-card overflow-hidden"> <div className="border border-border divide-y divide-border opacity-75 rounded-card overflow-hidden">
{completedItems.map((item) => ( {completedItems.map((item) => {
<div const cat = getItemCategory(item);
key={item.id} const displayContent = getItemDisplayContent(item);
onClick={() => handleToggleItem(item)}
className="px-3 py-1.5 bg-canvas/50 hover:bg-canvas/80 flex items-center justify-between gap-3 cursor-pointer transition-colors" return (
> <div
<div className="flex items-center gap-2.5 min-w-0"> key={item.id}
<CheckCircle2 className="w-4 h-4 text-accent flex-shrink-0" /> onClick={() => handleToggleItem(item)}
<span className="text-sm text-muted line-through truncate"> className="px-3 py-1.5 bg-canvas/50 hover:bg-canvas/80 flex items-center justify-between gap-3 cursor-pointer transition-colors"
{item.content} >
</span> <div className="flex items-center gap-2.5 min-w-0">
{item.quantity && ( <CheckCircle2 className="w-4 h-4 text-accent flex-shrink-0" />
<span className="text-xs text-faint">({item.quantity})</span> <span className="text-sm text-muted line-through truncate">
{displayContent}
</span>
{item.quantity && (
<span className="text-xs text-faint">({item.quantity})</span>
)}
{cat && (
<span className="text-[11px] px-1.5 py-0.2 badge-neutral text-muted rounded-full">
#{cat}
</span>
)}
</div>
{item.completedByName && (
<span className="text-xs text-faint">✓ {item.completedByName}</span>
)} )}
</div> </div>
{item.completedByName && ( );
<span className="text-xs text-faint">✓ {item.completedByName}</span> })}
)}
</div>
))}
</div> </div>
</div> </div>
)} )}
+18 -1
View File
@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -1887,12 +1888,28 @@ func (s *Store) CreateListItem(listID, content, category, quantity string, sortO
if content == "" { if content == "" {
return nil, errors.New("item content cannot be empty") return nil, errors.New("item content cannot be empty")
} }
category = strings.TrimSpace(category)
// Auto-extract hashtag if category is empty
if category == "" && strings.Contains(content, "#") {
re := regexp.MustCompile(`#([a-zA-Z0-9äöüÄÖÜß_-]+)`)
if m := re.FindStringSubmatch(content); len(m) > 1 {
category = m[1]
cleaned := strings.TrimSpace(re.ReplaceAllString(content, ""))
if cleaned != "" {
content = cleaned
}
}
} else if category != "" {
category = strings.TrimPrefix(category, "#")
}
now := domain.NowISO() now := domain.NowISO()
it := &domain.ListItem{ it := &domain.ListItem{
ID: domain.NewID("itm"), ID: domain.NewID("itm"),
ListID: listID, ListID: listID,
Content: content, Content: content,
Category: strings.TrimSpace(category), Category: category,
Quantity: strings.TrimSpace(quantity), Quantity: strings.TrimSpace(quantity),
IsCompleted: false, IsCompleted: false,
SortOrder: sortOrder, SortOrder: sortOrder,