experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(tags): Tags sidebar hides zero-usage tags

Tag rows persist after their items are untagged or soft-deleted; the
Tags sidebar now drops tags with no live (non-deleted) items so the
list reflects current usage. Filter lives in features/tags/home.js
(not at the datastore layer) — the Groups feature still needs zero-
usage tags so newly-promoted empty groups appear immediately. Adds
desktop e2e regression guard against re-introducing ghost tags.

+93 -5
+15 -5
features/tags/home.js
··· 186 186 } 187 187 } 188 188 189 - // Load all tags by frecency 189 + // Load all tags by frecency, then drop tags with no current items. 190 + // Hidden when no live (non-deleted) item references the tag — keeps the 191 + // sidebar reflecting present usage instead of historical tag rows. 190 192 const tagsResult = await api.datastore.getTagsByFrecency(); 191 193 if (tagsResult.success) { 192 - state.tags = tagsResult.data; 193 - debug && console.log('[tags] Loaded tags:', state.tags.length); 194 + const inUse = new Set(); 195 + for (const tagList of state.itemTags.values()) { 196 + for (const t of tagList) inUse.add(t.id); 197 + } 198 + state.tags = tagsResult.data.filter(t => inUse.has(t.id)); 199 + debug && console.log('[tags] Loaded tags (in-use only):', state.tags.length); 194 200 } else { 195 201 console.error('[tags] Failed to load tags:', tagsResult.error); 196 202 state.tags = []; ··· 209 215 const loadTags = async () => { 210 216 const tagsResult = await api.datastore.getTagsByFrecency(); 211 217 if (tagsResult.success) { 212 - state.tags = tagsResult.data; 213 - debug && console.log('[tags] Reloaded tags:', state.tags.length); 218 + const inUse = new Set(); 219 + for (const tagList of state.itemTags.values()) { 220 + for (const t of tagList) inUse.add(t.id); 221 + } 222 + state.tags = tagsResult.data.filter(t => inUse.has(t.id)); 223 + debug && console.log('[tags] Reloaded tags (in-use only):', state.tags.length); 214 224 } else { 215 225 console.error('[tags] Failed to reload tags:', tagsResult.error); 216 226 }
+78
tests/desktop/tag-list-current-usage.spec.ts
··· 1 + /** 2 + * Regression coverage: the Tags feature sidebar must only render tags 3 + * that have at least one current (non-deleted) item. Tag rows persist 4 + * for rename/color survival but ghost tags shouldn't clutter the UI. 5 + * 6 + * Filter lives in features/tags/home.js (loadData/loadTags), not at the 7 + * datastore layer — the Groups feature still needs zero-usage tags so 8 + * newly-promoted empty groups appear immediately. 9 + */ 10 + import { test, expect, DesktopApp } from '../fixtures/desktop-app'; 11 + import { Page } from '@playwright/test'; 12 + import { createPerDescribeApp } from '../helpers/test-app'; 13 + 14 + test.describe('tag list shows current-usage only @desktop', () => { 15 + let app: DesktopApp; 16 + let bgWindow: Page; 17 + 18 + test.beforeAll(async () => { 19 + ({ app, bgWindow } = await createPerDescribeApp('tag-current-usage')); 20 + }); 21 + 22 + test.afterAll(async () => { 23 + if (app) await app.close(); 24 + }); 25 + 26 + test('Tags sidebar hides tag with no items, shows tag with items', async () => { 27 + const stamp = Date.now(); 28 + const ghostName = `ghost-${stamp}`; 29 + const liveName = `live-${stamp}`; 30 + 31 + // Create a ghost tag (no items) 32 + const ghost = await bgWindow.evaluate(async (name: string) => { 33 + return await (window as any).app.datastore.getOrCreateTag(name); 34 + }, ghostName); 35 + expect(ghost.success).toBe(true); 36 + 37 + // Create a live tag with one item via inline-hashtag extraction 38 + const live = await bgWindow.evaluate(async (name: string) => { 39 + return await (window as any).app.datastore.addItem('text', { content: `present #${name}` }); 40 + }, liveName); 41 + expect(live.success).toBe(true); 42 + 43 + // Open Tags 44 + const openRes = await bgWindow.evaluate(async () => { 45 + return await (window as any).app.window.open('peek://tags/home.html', { width: 900, height: 700 }); 46 + }); 47 + expect(openRes.success).toBe(true); 48 + 49 + const tagsWindow = await app.getWindow('tags/home.html', 5000); 50 + expect(tagsWindow).toBeTruthy(); 51 + await tagsWindow.waitForLoadState('domcontentloaded'); 52 + 53 + // Wait for the live tag to render in the sidebar (proves load completed) 54 + await tagsWindow.waitForFunction( 55 + (name: string) => { 56 + const items = Array.from(document.querySelectorAll('.tag-list .tag-chip .tag-name')); 57 + return items.some(el => (el.textContent || '').includes(name)); 58 + }, 59 + liveName, 60 + { timeout: 10000 } 61 + ); 62 + 63 + const sidebarNames = await tagsWindow.$$eval('.tag-list .tag-chip .tag-name', (els: any[]) => 64 + els.map(el => (el.textContent || '').trim()) 65 + ); 66 + 67 + expect(sidebarNames.some(n => n.includes(liveName))).toBe(true); 68 + expect(sidebarNames.some(n => n.includes(ghostName))).toBe(false); 69 + 70 + if (openRes.id) { 71 + try { 72 + await bgWindow.evaluate(async (id: number) => { 73 + return await (window as any).app.window.close(id); 74 + }, openRes.id); 75 + } catch { /* may already be closed */ } 76 + } 77 + }); 78 + });