mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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:
parent
97abd251e3
commit
f09a84ac8e
7 changed files with 162 additions and 4 deletions
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
44
apps/mana/apps/web/src/lib/stores/scene-scope.test.ts
Normal file
44
apps/mana/apps/web/src/lib/stores/scene-scope.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue