feat(manacore): add Mukke, Presi, Context dashboard widgets

All apps now have dashboard widgets:
- Mukke: music library stats, recent/favorite songs, formatDuration()
- Presi: presentation decks, recent decks, deck counts
- Context: spaces, recent documents, token balance

Added 3 widget types to registry (16 total), 3 API services,
i18n translations (DE + EN), and 17 new tests (120 total).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-19 21:59:47 +01:00
parent 14b6a8934a
commit effa57fd61
12 changed files with 734 additions and 3 deletions

View file

@ -0,0 +1,127 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock $app/environment
vi.mock('$app/environment', () => ({
browser: true,
}));
// Mock auth store
vi.mock('$lib/stores/auth.svelte', () => ({
authStore: {
getAccessToken: vi.fn().mockResolvedValue('test-token'),
},
}));
import { contextService, type ContextSpace, type ContextDocument } from './context';
describe('contextService', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('getSpaces', () => {
it('should fetch spaces', async () => {
const spaces: ContextSpace[] = [
{
id: 's-1',
userId: 'u-1',
name: 'Research',
pinned: true,
prefix: 'R',
createdAt: '2026-01-01',
updatedAt: '2026-01-01',
},
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(spaces),
});
const result = await contextService.getSpaces();
expect(result.data).toEqual(spaces);
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/spaces'),
expect.any(Object)
);
});
});
describe('getRecentDocuments', () => {
it('should fetch recent documents with limit', async () => {
const docs: ContextDocument[] = [
{
id: 'd-1',
userId: 'u-1',
spaceId: 's-1',
title: 'Notes',
type: 'text',
shortId: 'RT1',
pinned: false,
createdAt: '2026-01-01',
updatedAt: '2026-03-01',
},
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(docs),
});
const result = await contextService.getRecentDocuments(3);
expect(result.data).toEqual(docs);
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/documents/recent?limit=3'),
expect.any(Object)
);
});
});
describe('getTokenBalance', () => {
it('should fetch token balance', async () => {
const balance = { tokenBalance: 5000, monthlyFreeTokens: 10000 };
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(balance),
});
const result = await contextService.getTokenBalance();
expect(result.data).toEqual(balance);
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/tokens/balance'),
expect.any(Object)
);
});
});
describe('getCounts', () => {
it('should return space count', async () => {
const spaces = [
{ id: 's-1', name: 'A' },
{ id: 's-2', name: 'B' },
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(spaces),
});
const result = await contextService.getCounts();
expect(result.data).toEqual({ spaces: 2, documents: 0 });
});
it('should return error on failure', async () => {
global.fetch = vi.fn().mockRejectedValue(new Error('Failed to fetch'));
const result = await contextService.getCounts();
expect(result.data).toBeNull();
expect(result.error).toBeTruthy();
});
});
});

View file

@ -0,0 +1,113 @@
/**
* Context API Service
*
* Fetches documents and spaces from the Context backend for dashboard widgets.
*/
import { browser } from '$app/environment';
import { createApiClient, type ApiResult } from '../base-client';
// Get Context API URL dynamically at runtime
function getContextApiUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_CONTEXT_API_URL__?: string })
.__PUBLIC_CONTEXT_API_URL__;
if (injectedUrl) {
return `${injectedUrl}/api/v1`;
}
}
return 'http://localhost:3020/api/v1';
}
// Lazy-initialized client
let _client: ReturnType<typeof createApiClient> | null = null;
function getClient() {
if (!_client) {
_client = createApiClient(getContextApiUrl());
}
return _client;
}
/**
* Space entity from Context backend
*/
export interface ContextSpace {
id: string;
userId: string;
name: string;
description?: string;
pinned: boolean;
prefix: string;
createdAt: string;
updatedAt: string;
}
/**
* Document entity from Context backend
*/
export interface ContextDocument {
id: string;
userId: string;
spaceId: string;
title: string;
content?: string;
type: 'text' | 'context' | 'prompt';
shortId: string;
pinned: boolean;
createdAt: string;
updatedAt: string;
}
/**
* Token balance from Context backend
*/
export interface TokenBalance {
tokenBalance: number;
monthlyFreeTokens: number;
}
/**
* Context service for dashboard widgets
*/
export const contextService = {
/**
* Get user's spaces
*/
async getSpaces(): Promise<ApiResult<ContextSpace[]>> {
return getClient().get<ContextSpace[]>('/spaces');
},
/**
* Get recent documents
*/
async getRecentDocuments(limit = 5): Promise<ApiResult<ContextDocument[]>> {
return getClient().get<ContextDocument[]>(`/documents/recent?limit=${limit}`);
},
/**
* Get token balance
*/
async getTokenBalance(): Promise<ApiResult<TokenBalance>> {
return getClient().get<TokenBalance>('/tokens/balance');
},
/**
* Get document and space counts
*/
async getCounts(): Promise<ApiResult<{ spaces: number; documents: number }>> {
const spacesResult = await this.getSpaces();
if (spacesResult.error || !spacesResult.data) {
return { data: null, error: spacesResult.error };
}
return {
data: {
spaces: spacesResult.data.length,
documents: 0, // Would need a separate count endpoint
},
error: null,
};
},
};

View file

@ -13,3 +13,11 @@ export { pictureService, type GeneratedImage, type GenerationStats } from './pic
export { manadeckService, type Deck, type Card, type LearningProgress } from './manadeck';
export { clockService, type Timer, type Alarm, type ClockStats } from './clock';
export { storageService, type StorageFile, type StorageStats } from './storage';
export { mukkeService, type Song, type MukkeStats } from './mukke';
export { presiService, type PresiDeck, type PresiStats } from './presi';
export {
contextService,
type ContextSpace,
type ContextDocument,
type TokenBalance,
} from './context';

View file

@ -0,0 +1,92 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock $app/environment
vi.mock('$app/environment', () => ({
browser: true,
}));
// Mock auth store
vi.mock('$lib/stores/auth.svelte', () => ({
authStore: {
getAccessToken: vi.fn().mockResolvedValue('test-token'),
},
}));
import { mukkeService } from './mukke';
describe('mukkeService', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('formatDuration', () => {
it('should format 0 seconds', () => {
expect(mukkeService.formatDuration(0)).toBe('0:00');
});
it('should format seconds only', () => {
expect(mukkeService.formatDuration(45)).toBe('0:45');
});
it('should format minutes and seconds', () => {
expect(mukkeService.formatDuration(185)).toBe('3:05');
});
it('should format hours', () => {
expect(mukkeService.formatDuration(3661)).toBe('1:01:01');
});
it('should pad seconds with zero', () => {
expect(mukkeService.formatDuration(60)).toBe('1:00');
});
it('should handle negative values', () => {
expect(mukkeService.formatDuration(-10)).toBe('0:00');
});
});
describe('getStats', () => {
it('should fetch library stats', async () => {
const mockStats = {
totalSongs: 42,
totalPlaylists: 5,
totalProjects: 3,
favoriteCount: 10,
totalPlayTime: 7200,
};
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockStats),
});
const result = await mukkeService.getStats();
expect(result.data).toEqual(mockStats);
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/library/stats'),
expect.any(Object)
);
});
});
describe('getRecentSongs', () => {
it('should fetch recent songs with default limit', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve([{ id: 's-1', title: 'Song 1' }]),
});
const result = await mukkeService.getRecentSongs();
expect(result.data).toHaveLength(1);
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/songs?limit=5'),
expect.any(Object)
);
});
});
});

View file

@ -0,0 +1,98 @@
/**
* Mukke API Service
*
* Fetches music library stats from the Mukke backend for dashboard widgets.
*/
import { browser } from '$app/environment';
import { createApiClient, type ApiResult } from '../base-client';
// Get Mukke API URL dynamically at runtime
function getMukkeApiUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_MUKKE_API_URL__?: string })
.__PUBLIC_MUKKE_API_URL__;
if (injectedUrl) {
return `${injectedUrl}`;
}
}
return 'http://localhost:3010';
}
// Lazy-initialized client
let _client: ReturnType<typeof createApiClient> | null = null;
function getClient() {
if (!_client) {
_client = createApiClient(getMukkeApiUrl());
}
return _client;
}
/**
* Song entity from Mukke backend
*/
export interface Song {
id: string;
userId: string;
title: string;
artist?: string;
album?: string;
genre?: string;
duration?: number;
favorite: boolean;
playCount: number;
lastPlayedAt?: string;
addedAt: string;
}
/**
* Music library statistics
*/
export interface MukkeStats {
totalSongs: number;
totalPlaylists: number;
totalProjects: number;
favoriteCount: number;
totalPlayTime: number; // In seconds
}
/**
* Mukke service for dashboard widgets
*/
export const mukkeService = {
/**
* Get library statistics
*/
async getStats(): Promise<ApiResult<MukkeStats>> {
return getClient().get<MukkeStats>('/library/stats');
},
/**
* Get recent songs
*/
async getRecentSongs(limit = 5): Promise<ApiResult<Song[]>> {
return getClient().get<Song[]>(`/songs?limit=${limit}`);
},
/**
* Get favorite songs
*/
async getFavoriteSongs(limit = 5): Promise<ApiResult<Song[]>> {
return getClient().get<Song[]>(`/songs?favorite=true&limit=${limit}`);
},
/**
* Format duration for display (seconds MM:SS or HH:MM:SS)
*/
formatDuration(seconds: number): string {
if (seconds <= 0) return '0:00';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) {
return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
return `${m}:${String(s).padStart(2, '0')}`;
},
};

View file

@ -0,0 +1,100 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock $app/environment
vi.mock('$app/environment', () => ({
browser: true,
}));
// Mock auth store
vi.mock('$lib/stores/auth.svelte', () => ({
authStore: {
getAccessToken: vi.fn().mockResolvedValue('test-token'),
},
}));
import { presiService, type PresiDeck } from './presi';
const mockDeck = (overrides: Partial<PresiDeck> = {}): PresiDeck => ({
id: 'd-1',
userId: 'u-1',
title: 'Test Deck',
isPublic: false,
createdAt: '2026-01-01',
updatedAt: '2026-03-01',
...overrides,
});
describe('presiService', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('getDecks', () => {
it('should fetch decks', async () => {
const decks = [mockDeck()];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(decks),
});
const result = await presiService.getDecks();
expect(result.data).toEqual(decks);
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/decks'),
expect.any(Object)
);
});
});
describe('getRecentDecks', () => {
it('should sort by updatedAt and limit', async () => {
const decks = [
mockDeck({ id: 'd-1', updatedAt: '2026-01-01' }),
mockDeck({ id: 'd-2', updatedAt: '2026-03-01' }),
mockDeck({ id: 'd-3', updatedAt: '2026-02-01' }),
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(decks),
});
const result = await presiService.getRecentDecks(2);
expect(result.data).toHaveLength(2);
expect(result.data![0].id).toBe('d-2');
expect(result.data![1].id).toBe('d-3');
});
});
describe('getDeckCount', () => {
it('should count total and public decks', async () => {
const decks = [
mockDeck({ isPublic: true }),
mockDeck({ id: 'd-2', isPublic: false }),
mockDeck({ id: 'd-3', isPublic: true }),
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(decks),
});
const result = await presiService.getDeckCount();
expect(result.data).toEqual({ total: 3, public: 2 });
});
it('should return error on failure', async () => {
global.fetch = vi.fn().mockRejectedValue(new Error('Failed to fetch'));
const result = await presiService.getDeckCount();
expect(result.data).toBeNull();
expect(result.error).toBeTruthy();
});
});
});

View file

@ -0,0 +1,101 @@
/**
* Presi API Service
*
* Fetches presentation decks from the Presi backend for dashboard widgets.
*/
import { browser } from '$app/environment';
import { createApiClient, type ApiResult } from '../base-client';
// Get Presi API URL dynamically at runtime
function getPresiApiUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_PRESI_API_URL__?: string })
.__PUBLIC_PRESI_API_URL__;
if (injectedUrl) {
return `${injectedUrl}/api`;
}
}
return 'http://localhost:3008/api';
}
// Lazy-initialized client
let _client: ReturnType<typeof createApiClient> | null = null;
function getClient() {
if (!_client) {
_client = createApiClient(getPresiApiUrl());
}
return _client;
}
/**
* Deck entity from Presi backend
*/
export interface PresiDeck {
id: string;
userId: string;
title: string;
description?: string;
themeId?: string;
isPublic: boolean;
createdAt: string;
updatedAt: string;
}
/**
* Presi statistics
*/
export interface PresiStats {
totalDecks: number;
publicDecks: number;
recentDecks: PresiDeck[];
}
/**
* Presi service for dashboard widgets
*/
export const presiService = {
/**
* Get user's decks
*/
async getDecks(): Promise<ApiResult<PresiDeck[]>> {
return getClient().get<PresiDeck[]>('/decks');
},
/**
* Get recent decks
*/
async getRecentDecks(limit = 5): Promise<ApiResult<PresiDeck[]>> {
const result = await this.getDecks();
if (result.error || !result.data) {
return result;
}
const sorted = result.data
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
.slice(0, limit);
return { data: sorted, error: null };
},
/**
* Get deck count
*/
async getDeckCount(): Promise<ApiResult<{ total: number; public: number }>> {
const result = await this.getDecks();
if (result.error || !result.data) {
return { data: null, error: result.error };
}
return {
data: {
total: result.data.length,
public: result.data.filter((d) => d.isPublic).length,
},
error: null,
};
},
};

View file

@ -101,6 +101,31 @@
"recent": "Kürzlich",
"empty": "Keine Dateien",
"open": "Storage öffnen"
},
"mukke": {
"title": "Musik",
"description": "Deine Musikbibliothek",
"songs": "Songs",
"playlists": "Playlists",
"favorites": "Favoriten",
"empty": "Keine Songs",
"open": "Mukke öffnen"
},
"presi": {
"title": "Präsentationen",
"description": "Deine Slide Decks",
"decks": "Decks",
"empty": "Keine Präsentationen",
"create": "Deck erstellen",
"open": "Presi öffnen"
},
"context": {
"title": "Context",
"description": "Deine Dokumente & Spaces",
"spaces": "Spaces",
"documents": "Dokumente",
"empty": "Keine Dokumente",
"open": "Context öffnen"
}
}
},

View file

@ -101,6 +101,31 @@
"recent": "Recent",
"empty": "No files",
"open": "Open Storage"
},
"mukke": {
"title": "Music",
"description": "Your music library",
"songs": "Songs",
"playlists": "Playlists",
"favorites": "Favorites",
"empty": "No songs",
"open": "Open Mukke"
},
"presi": {
"title": "Presentations",
"description": "Your slide decks",
"decks": "Decks",
"empty": "No presentations",
"create": "Create deck",
"open": "Open Presi"
},
"context": {
"title": "Context",
"description": "Your documents & spaces",
"spaces": "Spaces",
"documents": "Documents",
"empty": "No documents",
"open": "Open Context"
}
}
},

View file

@ -213,6 +213,9 @@ export const dashboardStore = {
'chat-recent',
'contacts-favorites',
'zitare-quote',
'mukke-library',
'presi-decks',
'context-docs',
] as WidgetType[]
).filter((type) => {
const meta = getWidgetMeta(type);

View file

@ -8,8 +8,8 @@ import {
} from './dashboard';
describe('WIDGET_REGISTRY', () => {
it('should contain 13 widget definitions', () => {
expect(WIDGET_REGISTRY).toHaveLength(13);
it('should contain 16 widget definitions', () => {
expect(WIDGET_REGISTRY).toHaveLength(16);
});
it('should have unique types for all widgets', () => {
@ -47,6 +47,9 @@ describe('WIDGET_REGISTRY', () => {
'manadeck',
'clock',
'storage',
'mukke',
'presi',
'context',
'mana-core-auth',
undefined,
];
@ -70,6 +73,9 @@ describe('WIDGET_REGISTRY', () => {
expect(types).toContain('manadeck-progress');
expect(types).toContain('clock-timers');
expect(types).toContain('storage-usage');
expect(types).toContain('mukke-library');
expect(types).toContain('presi-decks');
expect(types).toContain('context-docs');
});
it('should have i18n-style name keys', () => {

View file

@ -20,7 +20,10 @@ export type WidgetType =
| 'picture-recent' // Picture API: recent generations
| 'manadeck-progress' // ManaDeck API: learning progress
| 'clock-timers' // Clock: active timers and alarms
| 'storage-usage'; // Storage: file storage stats
| 'storage-usage' // Storage: file storage stats
| 'mukke-library' // Mukke: music library stats
| 'presi-decks' // Presi: recent presentations
| 'context-docs'; // Context: recent documents & spaces
/**
* Widget size - maps to CSS Grid columns
@ -115,6 +118,9 @@ export interface WidgetMeta {
| 'manadeck'
| 'clock'
| 'storage'
| 'mukke'
| 'presi'
| 'context'
| 'mana-core-auth';
}
@ -238,6 +244,33 @@ export const WIDGET_REGISTRY: WidgetMeta[] = [
allowMultiple: false,
requiredBackend: 'storage',
},
{
type: 'mukke-library',
nameKey: 'dashboard.widgets.mukke.title',
descriptionKey: 'dashboard.widgets.mukke.description',
icon: '🎵',
defaultSize: 'medium',
allowMultiple: false,
requiredBackend: 'mukke',
},
{
type: 'presi-decks',
nameKey: 'dashboard.widgets.presi.title',
descriptionKey: 'dashboard.widgets.presi.description',
icon: '📊',
defaultSize: 'medium',
allowMultiple: false,
requiredBackend: 'presi',
},
{
type: 'context-docs',
nameKey: 'dashboard.widgets.context.title',
descriptionKey: 'dashboard.widgets.context.description',
icon: '📝',
defaultSize: 'medium',
allowMultiple: false,
requiredBackend: 'context',
},
];
/**