feat(workbench): scope-aware empty state for scoped modules

Phase 1 of the scene-scope empty state plan (docs/plans/scene-scope-
empty-state.md). When the active scene's scope tags filter a module
down to zero results, the ListView now shows a dedicated empty state
with a one-click "Bereich zurücksetzen" button instead of the generic
"Keine Aufgaben"/"Keine Treffer" message. Previously the user couldn't
tell whether the list was empty because of missing data or because of
the scope filter.

- New `ScopeEmptyState.svelte` shared component.
- New `hasActiveSceneScope()` reactive helper on the scene-scope store.
- Wired into todo, notes, calendar, contacts ListViews — the four
  modules that currently use `filterBySceneScopeBatch`.
- 4 unit tests for the scope primitives.

Phase 2 (per-module hidden count) and Phase 3 (persistent scope badge)
remain optional follow-ups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-18 16:52:14 +02:00
parent 97abd251e3
commit f09a84ac8e
7 changed files with 162 additions and 4 deletions

View file

@ -0,0 +1,82 @@
<!--
ScopeEmptyState — shown inside a module's ListView when the list is
empty *because* the active scene's scope tags filter everything out.
Gives the user a one-click way to clear the scope instead of leaving
them to figure out why their records vanished.
Usage:
{#if filtered.length === 0}
{#if hasActiveSceneScope()}
<ScopeEmptyState />
{:else}
<p class="empty">Keine Aufgaben</p>
{/if}
{/if}
-->
<script lang="ts">
import { Funnel } from '@mana/shared-icons';
import { workbenchScenesStore } from '$lib/stores/workbench-scenes.svelte';
interface Props {
/** Optional module label — e.g. "Aufgaben". Omit for the generic copy. */
label?: string;
}
let { label }: Props = $props();
async function handleClear() {
const id = workbenchScenesStore.activeSceneId;
if (!id) return;
await workbenchScenesStore.setSceneScopeTags(id, undefined);
}
let primaryLine = $derived(
label
? `Aktive Bereichsfilter verbergen alle ${label}.`
: 'Aktive Bereichsfilter verbergen alles.'
);
</script>
<div class="scope-empty">
<Funnel size={20} weight="duotone" />
<p class="scope-empty-line">{primaryLine}</p>
<button type="button" class="scope-clear-btn" onclick={handleClear}>
Bereich zurücksetzen
</button>
</div>
<style>
.scope-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 2rem 1rem;
color: hsl(var(--color-muted-foreground));
text-align: center;
}
.scope-empty-line {
margin: 0;
font-size: 0.8125rem;
line-height: 1.4;
max-width: 24ch;
}
.scope-clear-btn {
margin-top: 0.25rem;
padding: 0.3125rem 0.75rem;
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--color-primary));
background: color-mix(in oklab, hsl(var(--color-primary)) 10%, transparent);
border: 1px solid color-mix(in oklab, hsl(var(--color-primary)) 30%, transparent);
border-radius: 9999px;
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s;
}
.scope-clear-btn:hover {
background: color-mix(in oklab, hsl(var(--color-primary)) 18%, transparent);
border-color: color-mix(in oklab, hsl(var(--color-primary)) 45%, transparent);
}
</style>

View file

@ -17,6 +17,8 @@
import { useItemContextMenu } from '$lib/data/item-context-menu.svelte';
import { transcribeAudio } from '$lib/voice/transcribe';
import FloatingInputBar from '$lib/components/FloatingInputBar.svelte';
import ScopeEmptyState from '$lib/components/workbench/ScopeEmptyState.svelte';
import { hasActiveSceneScope } from '$lib/stores/scene-scope.svelte';
let { navigate, goBack, params }: ViewProps = $props();
@ -219,7 +221,11 @@
{/each}
{#if upcomingEvents.length === 0}
<p class="empty">Keine Termine</p>
{#if hasActiveSceneScope()}
<ScopeEmptyState label="Termine" />
{:else}
<p class="empty">Keine Termine</p>
{/if}
{/if}
</div>

View file

@ -17,6 +17,8 @@
import { useAllTags, getTagsByIds } from '@mana/shared-stores';
import { addTagId } from '$lib/data/tag-mutations';
import { useItemContextMenu } from '$lib/data/item-context-menu.svelte';
import ScopeEmptyState from '$lib/components/workbench/ScopeEmptyState.svelte';
import { hasActiveSceneScope } from '$lib/stores/scene-scope.svelte';
let { navigate, goBack, params }: ViewProps = $props();
@ -175,7 +177,11 @@
{/each}
{#if filtered().length === 0}
<p class="empty">Keine Kontakte gefunden</p>
{#if hasActiveSceneScope()}
<ScopeEmptyState label="Kontakte" />
{:else}
<p class="empty">Keine Kontakte gefunden</p>
{/if}
{/if}
</div>

View file

@ -13,6 +13,8 @@
import { PencilSimple, Trash, PushPin } from '@mana/shared-icons';
import FloatingInputBar from '$lib/components/FloatingInputBar.svelte';
import AgentDot from '$lib/components/ai/AgentDot.svelte';
import ScopeEmptyState from '$lib/components/workbench/ScopeEmptyState.svelte';
import { hasActiveSceneScope } from '$lib/stores/scene-scope.svelte';
let { navigate, goBack, params }: ViewProps = $props();
@ -212,7 +214,11 @@
</div>
{#if notes.length === 0}
<p class="empty">Erstelle deine erste Notiz.</p>
{#if hasActiveSceneScope()}
<ScopeEmptyState label="Notizen" />
{:else}
<p class="empty">Erstelle deine erste Notiz.</p>
{/if}
{/if}
<FloatingInputBar

View file

@ -17,6 +17,8 @@
import { addTagId } from '$lib/data/tag-mutations';
import { useItemContextMenu } from '$lib/data/item-context-menu.svelte';
import FloatingInputBar from '$lib/components/FloatingInputBar.svelte';
import ScopeEmptyState from '$lib/components/workbench/ScopeEmptyState.svelte';
import { hasActiveSceneScope } from '$lib/stores/scene-scope.svelte';
let { navigate, goBack, params }: ViewProps = $props();
@ -197,7 +199,11 @@
{/each}
{#if sorted.length === 0}
<p class="empty">Keine Aufgaben</p>
{#if hasActiveSceneScope()}
<ScopeEmptyState label="Aufgaben" />
{:else}
<p class="empty">Keine Aufgaben</p>
{/if}
{/if}
</div>

View file

@ -20,6 +20,14 @@ export function getSceneScopeTagIds(): readonly string[] | undefined {
return _scopeTagIds;
}
/**
* True when the active scene is filtering by at least one scope tag.
* Reactive safe to read inside a $derived or template.
*/
export function hasActiveSceneScope(): boolean {
return !!_scopeTagIds?.length;
}
/**
* Batch filter using a pre-fetched tag map. Preferred for list queries
* (1 Dexie call instead of N).

View file

@ -0,0 +1,44 @@
/**
* Unit tests for the reactive scope-tag primitives backing the workbench
* scene scope. These live in the module scope of scene-scope.svelte.ts
* and are shared between AI scope-context, ListView empty states, and
* module queries via filterBySceneScopeBatch.
*/
import { afterEach, describe, expect, it } from 'vitest';
import {
setSceneScopeTagIds,
getSceneScopeTagIds,
hasActiveSceneScope,
} from './scene-scope.svelte';
afterEach(() => {
setSceneScopeTagIds(undefined);
});
describe('scene scope state', () => {
it('starts empty', () => {
expect(getSceneScopeTagIds()).toBeUndefined();
expect(hasActiveSceneScope()).toBe(false);
});
it('setSceneScopeTagIds populates the state', () => {
setSceneScopeTagIds(['tag-1', 'tag-2']);
expect(getSceneScopeTagIds()).toEqual(['tag-1', 'tag-2']);
expect(hasActiveSceneScope()).toBe(true);
});
it('empty array is normalized to undefined', () => {
setSceneScopeTagIds([]);
expect(getSceneScopeTagIds()).toBeUndefined();
expect(hasActiveSceneScope()).toBe(false);
});
it('explicit undefined clears any active scope', () => {
setSceneScopeTagIds(['tag-1']);
expect(hasActiveSceneScope()).toBe(true);
setSceneScopeTagIds(undefined);
expect(hasActiveSceneScope()).toBe(false);
expect(getSceneScopeTagIds()).toBeUndefined();
});
});