refactor(mana/web): extract addTagId / removeTagIdWithUndo helpers

Four ListViews (calendar, contacts, places, todo) reimplemented the
same drag-drop tag append logic, and four matching DetailViews
reimplemented the same "remove tag with undo toast" logic. Extract
both into pure helpers in $lib/data/tag-mutations.ts that take a
store-agnostic update function — works for the standard tagIds
modules and for todo's metadata.labelIds via tasksStore.updateLabels.

Side win: places/views/DetailView's removeTag had no undo toast
(every other module did). Consolidating fixes the inconsistency.

zitare is the outlier — its drag target is a Quote, but the tags
live on a (possibly-not-yet-existing) Favorite record. Stays custom.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-08 22:26:15 +02:00
parent 1f26aa4f2f
commit 3dc16bb885
9 changed files with 96 additions and 39 deletions

View file

@ -0,0 +1,67 @@
/**
* Tag mutation helpers shared add/remove logic for entity.tagIds fields.
*
* Every module that supports tagging on its records (calendar events,
* contacts, places, todo tasks, ) reimplemented the same two operations:
*
* - "append a tag if not already present" (drag-drop from tag strip)
* - "remove a tag, with undo toast" (click on a tag pill in the detail view)
*
* These helpers stay store-agnostic the caller passes the current
* `tagIds` array and an `update` function that knows which store/field to
* write to. That keeps them usable for tasks (which write `metadata.labelIds`
* via `tasksStore.updateLabels`) as well as the standard `tagIds` modules.
*/
import { toastStore } from '@mana/shared-ui/toast';
type UpdateFn = (next: string[]) => Promise<void> | void;
/**
* Append `tagId` to `current` and call `update` with the result.
* No-op if the tag is already present.
*
* @example
* ```svelte
* use:dropTarget={{
* accepts: ['tag'],
* onDrop: (p) => addTagId(
* contact.tagIds ?? [],
* (p.data as TagDragData).id,
* (next) => contactsStore.updateTagIds(contact.id, next),
* ),
* }}
* ```
*/
export async function addTagId(current: string[], tagId: string, update: UpdateFn): Promise<void> {
if (current.includes(tagId)) return;
await update([...current, tagId]);
}
/**
* Remove `tagId` from `current`, call `update` with the filtered list,
* and show a toast with an undo action that reinstates the original list.
*
* @example
* ```ts
* async function removeTag(tagId: string) {
* await removeTagIdWithUndo(
* contact.tagIds ?? [],
* tagId,
* (next) => contactsStore.updateTagIds(contactId, next),
* );
* }
* ```
*/
export async function removeTagIdWithUndo(
current: string[],
tagId: string,
update: UpdateFn,
undoLabel = 'Tag entfernt'
): Promise<void> {
const removed = current.filter((id) => id !== tagId);
await update(removed);
toastStore.undo(undoLabel, () => {
void update(current);
});
}

View file

@ -14,6 +14,7 @@
import { dropTarget, dragSource } from '@mana/shared-ui/dnd';
import type { TagDragData } from '@mana/shared-ui/dnd';
import { useAllTags, getTagsByIds } from '@mana/shared-stores';
import { addTagId } from '$lib/data/tag-mutations';
let { navigate, goBack, params }: ViewProps = $props();
@ -23,10 +24,9 @@
function handleTagDrop(eventId: string, tagData: TagDragData) {
const event = allItems.find((e) => e.id === eventId);
if (!event) return;
const current = event.tagIds ?? [];
if (!current.includes(tagData.id)) {
eventsStore.updateTagIds(eventId, [...current, tagData.id]);
}
void addTagId(event.tagIds ?? [], tagData.id, (next) =>
eventsStore.updateTagIds(eventId, next)
);
}
const itemsQuery = useAllCalendarItems();

View file

@ -15,6 +15,7 @@
import { useAllTags, getTagsByIds } from '@mana/shared-stores';
import LinkedItems from '$lib/components/links/LinkedItems.svelte';
import { toastStore } from '@mana/shared-ui/toast';
import { removeTagIdWithUndo } from '$lib/data/tag-mutations';
let { navigate, params, goBack }: ViewProps = $props();
let eventId = $derived(params.eventId as string);
@ -68,12 +69,9 @@
let eventTags = $derived(getTagsByIds(allTags, detail.entity?.tagIds ?? []));
async function removeTag(tagId: string) {
const current = detail.entity?.tagIds ?? [];
const removed = current.filter((id) => id !== tagId);
await eventsStore.updateTagIds(eventId, removed);
toastStore.undo('Tag entfernt', () => {
eventsStore.updateTagIds(eventId, current);
});
await removeTagIdWithUndo(detail.entity?.tagIds ?? [], tagId, (next) =>
eventsStore.updateTagIds(eventId, next)
);
}
async function saveField() {

View file

@ -14,6 +14,7 @@
import { dropTarget, dragSource } from '@mana/shared-ui/dnd';
import type { TagDragData } from '@mana/shared-ui/dnd';
import { useAllTags, getTagsByIds } from '@mana/shared-stores';
import { addTagId } from '$lib/data/tag-mutations';
let { navigate, goBack, params }: ViewProps = $props();
@ -23,10 +24,9 @@
function handleTagDrop(contactId: string, tagData: TagDragData) {
const contact = contacts.find((c) => c.id === contactId);
if (!contact) return;
const current = contact.tagIds ?? [];
if (!current.includes(tagData.id)) {
contactsStore.updateTagIds(contactId, [...current, tagData.id]);
}
void addTagId(contact.tagIds ?? [], tagData.id, (next) =>
contactsStore.updateTagIds(contactId, next)
);
}
let contacts$ = useLiveQueryWithDefault(async () => {

View file

@ -11,7 +11,7 @@
import type { LocalContact } from '../types';
import { useAllTags, getTagsByIds } from '@mana/shared-stores';
import LinkedItems from '$lib/components/links/LinkedItems.svelte';
import { toastStore } from '@mana/shared-ui/toast';
import { removeTagIdWithUndo } from '$lib/data/tag-mutations';
let { navigate, params, goBack }: ViewProps = $props();
let contactId = $derived(params.contactId as string);
@ -58,12 +58,9 @@
let contactTags = $derived(getTagsByIds(allTags, detail.entity?.tagIds ?? []));
async function removeTag(tagId: string) {
const current = detail.entity?.tagIds ?? [];
const removed = current.filter((id) => id !== tagId);
await contactsStore.updateTagIds(contactId, removed);
toastStore.undo('Tag entfernt', () => {
contactsStore.updateTagIds(contactId, current);
});
await removeTagIdWithUndo(detail.entity?.tagIds ?? [], tagId, (next) =>
contactsStore.updateTagIds(contactId, next)
);
}
function initials(c: LocalContact): string {

View file

@ -14,6 +14,7 @@
import { dropTarget, dragSource } from '@mana/shared-ui/dnd';
import type { TagDragData } from '@mana/shared-ui/dnd';
import { useAllTags, getTagsByIds } from '@mana/shared-stores';
import { addTagId } from '$lib/data/tag-mutations';
let { navigate, goBack, params }: ViewProps = $props();
@ -23,10 +24,9 @@
function handleTagDrop(placeId: string, tagData: TagDragData) {
const place = places.find((p) => p.id === placeId);
if (!place) return;
const current = place.tagIds ?? [];
if (!current.includes(tagData.id)) {
placesStore.updateTagIds(placeId, [...current, tagData.id]);
}
void addTagId(place.tagIds ?? [], tagData.id, (next) =>
placesStore.updateTagIds(placeId, next)
);
}
let places = $state<LocalPlace[]>([]);

View file

@ -13,6 +13,7 @@
import type { LocalPlace, PlaceCategory, LocalLocationLog } from '../types';
import { useAllTags, getTagsByIds } from '@mana/shared-stores';
import LinkedItems from '$lib/components/links/LinkedItems.svelte';
import { removeTagIdWithUndo } from '$lib/data/tag-mutations';
let { navigate, params, goBack }: ViewProps = $props();
let placeId = $derived(params.placeId as string);
@ -64,10 +65,8 @@
];
async function removeTag(tagId: string) {
const current = detail.entity?.tagIds ?? [];
await placesStore.updateTagIds(
placeId,
current.filter((id) => id !== tagId)
await removeTagIdWithUndo(detail.entity?.tagIds ?? [], tagId, (next) =>
placesStore.updateTagIds(placeId, next)
);
}

View file

@ -20,6 +20,7 @@
import { dropTarget, dragSource } from '@mana/shared-ui/dnd';
import type { TagDragData } from '@mana/shared-ui/dnd';
import { useAllTags, getTagsByIds } from '@mana/shared-stores';
import { addTagId } from '$lib/data/tag-mutations';
import VoiceCaptureBar from '$lib/components/voice/VoiceCaptureBar.svelte';
let { navigate, goBack, params }: ViewProps = $props();
@ -34,10 +35,7 @@
function handleTagDrop(taskId: string, tagData: TagDragData) {
const task = tasks.find((t) => t.id === taskId);
if (!task) return;
const current = getTaskTagIds(task);
if (!current.includes(tagData.id)) {
tasksStore.updateLabels(taskId, [...current, tagData.id]);
}
void addTagId(getTaskTagIds(task), tagData.id, (next) => tasksStore.updateLabels(taskId, next));
}
type ViewFilter = 'inbox' | 'today' | 'overdue';

View file

@ -17,6 +17,7 @@
import { useAllTags, getTagsByIds } from '@mana/shared-stores';
import LinkedItems from '$lib/components/links/LinkedItems.svelte';
import { toastStore } from '@mana/shared-ui/toast';
import { removeTagIdWithUndo } from '$lib/data/tag-mutations';
let { navigate, params, goBack }: ViewProps = $props();
let taskId = $derived(params.taskId as string);
@ -73,12 +74,9 @@
let taskTags = $derived(getTagsByIds(allTags, getTaskTagIds()));
async function removeTag(tagId: string) {
const current = getTaskTagIds();
const removed = current.filter((id) => id !== tagId);
await tasksStore.updateLabels(taskId, removed);
toastStore.undo('Tag entfernt', () => {
tasksStore.updateLabels(taskId, current);
});
await removeTagIdWithUndo(getTaskTagIds(), tagId, (next) =>
tasksStore.updateLabels(taskId, next)
);
}
async function saveField() {