chore(mana/web): pre-launch cleanup — remove ghost backend API clients

Twelve `*-api.mana.how` Cloudflare hostnames (todo, calendar, contacts,
chat, storage, cards, music, picture, presi, zitare, clock, context)
plus their matching `lib/api/services/*.ts` clients still existed in
the unified web app even though the per-app HTTP backends had been
gone since the local-first migration. Their tunnel routes pointed at
ports nothing listened on, so every consumer call returned 502 — and
the corresponding `__PUBLIC_*_API_URL__` runtime variables were
silently injected into every page render.

The only live consumer was `qrExportService` (committed separately as
part of the rewrite to read directly from Dexie). Two admin / data-
management pages also imported the types but were already migrated
to the unified `adminService` / `myDataService` clients.

Removed:
- Twenty-four files deleted: the twelve `lib/api/services/*.ts`
  clients plus their `*.test.ts` siblings.
- `services/index.ts` collapsed from a thirteen-symbol re-export
  to just the four genuinely server-bound services
  (`adminService`, `landing`, `myDataService`, `qrExportService`).
- `hooks.server.ts` no longer reads or injects any of the twelve
  `__PUBLIC_*_API_URL__` runtime variables, and the CSP `connect-src`
  list shrank by the same amount. Memoro server URL also removed
  since the unified `memoro` module is fully local-first and never
  hit the standalone server (the docker-compose service stays
  defined for the mobile app).
- `routes/status/+page.server.ts` stops probing the dead per-app
  health endpoints — only `auth`, `sync`, `uload-server`, `media`
  and `llm` remain in the public status page.

The cloudflared tunnel ingress entries for these hostnames were also
removed in `~/.cloudflared/config.yml` on the Mac Mini (not in this
repo) so the formerly-502 responses now return 404 from the edge.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-07 22:30:24 +02:00
parent c27cb84f28
commit 3a473897ec
24 changed files with 42 additions and 2758 deletions

View file

@ -6,44 +6,37 @@ import { setSecurityHeaders } from '@mana/shared-utils/security-headers';
* Server hooks for Mana web app
*
* Injects runtime environment variables into the HTML for client-side access.
* This is necessary because SvelteKit's $env/static/public bakes values at build time,
* but Docker containers need runtime configuration.
* This is necessary because SvelteKit's $env/static/public bakes values at
* build time, but Docker containers need runtime configuration.
*
* The set of injected URLs is intentionally short:
* - Auth mana-auth (login, sessions, GDPR endpoints)
* - Sync mana-sync (local-first push/pull/WS for every module)
* - Media mana-media (CAS / thumbnails)
* - LLM mana-llm (server-side LLM proxy)
* - Events mana-events (public RSVP flow)
* - Uload server standalone short-link redirect/click tracking
* - Memoro server standalone voice memo processing
* - Glitchtip DSN client-side error reporting
*
* Per-app HTTP backends (todo-api, calendar-api, contacts-api, chat-api,
* storage-api, cards-api, mukke-api, nutriphi-api, picture-api, presi-api,
* zitare-api, clock-api, context-api) were removed in the pre-launch
* ghost-API cleanup every product module now talks to mana-sync directly.
*/
// Auth URL
const PUBLIC_MANA_AUTH_URL_CLIENT =
process.env.PUBLIC_MANA_AUTH_URL_CLIENT || process.env.PUBLIC_MANA_AUTH_URL || '';
// Backend URLs for dashboard widgets
const PUBLIC_TODO_API_URL_CLIENT =
process.env.PUBLIC_TODO_API_URL_CLIENT || process.env.PUBLIC_TODO_API_URL || '';
const PUBLIC_CALENDAR_API_URL_CLIENT =
process.env.PUBLIC_CALENDAR_API_URL_CLIENT || process.env.PUBLIC_CALENDAR_API_URL || '';
const PUBLIC_CLOCK_API_URL_CLIENT =
process.env.PUBLIC_CLOCK_API_URL_CLIENT || process.env.PUBLIC_CLOCK_API_URL || '';
const PUBLIC_CONTACTS_API_URL_CLIENT =
process.env.PUBLIC_CONTACTS_API_URL_CLIENT || process.env.PUBLIC_CONTACTS_API_URL || '';
const PUBLIC_GLITCHTIP_DSN = process.env.PUBLIC_GLITCHTIP_DSN || '';
// Sync server URL (WebSocket)
const PUBLIC_SYNC_SERVER_URL_CLIENT =
process.env.PUBLIC_SYNC_SERVER_URL_CLIENT || process.env.PUBLIC_SYNC_SERVER_URL || '';
// Additional backend URLs
const PUBLIC_CHAT_API_URL_CLIENT =
process.env.PUBLIC_CHAT_API_URL_CLIENT || process.env.PUBLIC_CHAT_API_URL || '';
const PUBLIC_STORAGE_API_URL_CLIENT =
process.env.PUBLIC_STORAGE_API_URL_CLIENT || process.env.PUBLIC_STORAGE_API_URL || '';
const PUBLIC_CARDS_API_URL_CLIENT =
process.env.PUBLIC_CARDS_API_URL_CLIENT || process.env.PUBLIC_CARDS_API_URL || '';
const PUBLIC_MUSIC_API_URL_CLIENT =
process.env.PUBLIC_MUSIC_API_URL_CLIENT || process.env.PUBLIC_MUSIC_API_URL || '';
const PUBLIC_NUTRIPHI_API_URL_CLIENT =
process.env.PUBLIC_NUTRIPHI_API_URL_CLIENT || process.env.PUBLIC_NUTRIPHI_API_URL || '';
const PUBLIC_ULOAD_SERVER_URL_CLIENT =
process.env.PUBLIC_ULOAD_SERVER_URL_CLIENT || process.env.PUBLIC_ULOAD_SERVER_URL || '';
const PUBLIC_MEMORO_SERVER_URL_CLIENT =
process.env.PUBLIC_MEMORO_SERVER_URL_CLIENT || process.env.PUBLIC_MEMORO_SERVER_URL || '';
// memoro-server is intentionally not injected — the unified web app's memoro
// module is fully local-first (recorder + Dexie + sync) and never calls the
// standalone server. The memoro-server compose service still exists for the
// mobile app, but mana.how does not depend on it.
const PUBLIC_MANA_MEDIA_URL_CLIENT =
process.env.PUBLIC_MANA_MEDIA_URL_CLIENT || process.env.PUBLIC_MANA_MEDIA_URL || '';
const PUBLIC_MANA_LLM_URL_CLIENT =
@ -94,18 +87,8 @@ export const handle: Handle = async ({ event, resolve }) => {
transformPageChunk: ({ html }) => {
const envScript = `<script>
window.__PUBLIC_MANA_AUTH_URL__ = ${JSON.stringify(PUBLIC_MANA_AUTH_URL_CLIENT)};
window.__PUBLIC_TODO_API_URL__ = ${JSON.stringify(PUBLIC_TODO_API_URL_CLIENT)};
window.__PUBLIC_CALENDAR_API_URL__ = ${JSON.stringify(PUBLIC_CALENDAR_API_URL_CLIENT)};
window.__PUBLIC_CLOCK_API_URL__ = ${JSON.stringify(PUBLIC_CLOCK_API_URL_CLIENT)};
window.__PUBLIC_CONTACTS_API_URL__ = ${JSON.stringify(PUBLIC_CONTACTS_API_URL_CLIENT)};
window.__PUBLIC_SYNC_SERVER_URL__ = ${JSON.stringify(PUBLIC_SYNC_SERVER_URL_CLIENT)};
window.__PUBLIC_CHAT_API_URL__ = ${JSON.stringify(PUBLIC_CHAT_API_URL_CLIENT)};
window.__PUBLIC_STORAGE_API_URL__ = ${JSON.stringify(PUBLIC_STORAGE_API_URL_CLIENT)};
window.__PUBLIC_CARDS_API_URL__ = ${JSON.stringify(PUBLIC_CARDS_API_URL_CLIENT)};
window.__PUBLIC_MUSIC_API_URL__ = ${JSON.stringify(PUBLIC_MUSIC_API_URL_CLIENT)};
window.__PUBLIC_NUTRIPHI_API_URL__ = ${JSON.stringify(PUBLIC_NUTRIPHI_API_URL_CLIENT)};
window.__PUBLIC_ULOAD_SERVER_URL__ = ${JSON.stringify(PUBLIC_ULOAD_SERVER_URL_CLIENT)};
window.__PUBLIC_MEMORO_SERVER_URL__ = ${JSON.stringify(PUBLIC_MEMORO_SERVER_URL_CLIENT)};
window.__PUBLIC_MANA_MEDIA_URL__ = ${JSON.stringify(PUBLIC_MANA_MEDIA_URL_CLIENT)};
window.__PUBLIC_MANA_LLM_URL__ = ${JSON.stringify(PUBLIC_MANA_LLM_URL_CLIENT)};
window.__PUBLIC_MANA_EVENTS_URL__ = ${JSON.stringify(PUBLIC_MANA_EVENTS_URL_CLIENT)};
@ -119,18 +102,8 @@ window.__PUBLIC_GLITCHTIP_DSN__ = ${JSON.stringify(PUBLIC_GLITCHTIP_DSN)};
setSecurityHeaders(response, {
connectSrc: [
PUBLIC_MANA_AUTH_URL_CLIENT,
PUBLIC_TODO_API_URL_CLIENT,
PUBLIC_CALENDAR_API_URL_CLIENT,
PUBLIC_CLOCK_API_URL_CLIENT,
PUBLIC_CONTACTS_API_URL_CLIENT,
PUBLIC_SYNC_SERVER_URL_CLIENT,
PUBLIC_CHAT_API_URL_CLIENT,
PUBLIC_STORAGE_API_URL_CLIENT,
PUBLIC_CARDS_API_URL_CLIENT,
PUBLIC_MUSIC_API_URL_CLIENT,
PUBLIC_NUTRIPHI_API_URL_CLIENT,
PUBLIC_ULOAD_SERVER_URL_CLIENT,
PUBLIC_MEMORO_SERVER_URL_CLIENT,
PUBLIC_MANA_MEDIA_URL_CLIENT,
PUBLIC_MANA_LLM_URL_CLIENT,
PUBLIC_MANA_EVENTS_URL_CLIENT,

View file

@ -1,141 +0,0 @@
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 { calendarService, type CalendarEvent, type Calendar } from './calendar';
describe('calendarService', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('getUpcomingEvents', () => {
it('should fetch upcoming events with date range params', async () => {
const events: CalendarEvent[] = [
{
id: 'e-1',
calendarId: 'cal-1',
userId: 'u-1',
title: 'Meeting',
startTime: '2026-03-20T10:00:00Z',
endTime: '2026-03-20T11:00:00Z',
isAllDay: false,
timezone: 'Europe/Berlin',
status: 'confirmed',
createdAt: '2026-01-01',
updatedAt: '2026-01-01',
},
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ events }),
});
const result = await calendarService.getUpcomingEvents(7);
expect(result.data).toEqual(events);
expect(result.error).toBeNull();
expect(global.fetch).toHaveBeenCalledWith(
expect.stringMatching(/\/events\?startDate=\d{4}-\d{2}-\d{2}&endDate=\d{4}-\d{2}-\d{2}/),
expect.any(Object)
);
});
it('should return empty array when no events', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ events: [] }),
});
const result = await calendarService.getUpcomingEvents();
expect(result.data).toEqual([]);
});
it('should return error on network failure', async () => {
global.fetch = vi.fn().mockRejectedValue(new Error('Failed to fetch'));
const result = await calendarService.getUpcomingEvents();
expect(result.data).toBeNull();
expect(result.error).toBeTruthy();
});
});
describe('getTodayEvents', () => {
it('should fetch events for today', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ events: [{ id: 'e-1', title: 'Today Event' }] }),
});
const result = await calendarService.getTodayEvents();
expect(result.data).toHaveLength(1);
// Both startDate and endDate should be the same (today)
const url = (global.fetch as any).mock.calls[0][0];
const params = new URL(url).searchParams;
expect(params.get('startDate')).toBe(params.get('endDate'));
});
});
describe('getCalendars', () => {
it('should fetch all calendars', async () => {
const calendars: Calendar[] = [
{
id: 'cal-1',
userId: 'u-1',
name: 'Work',
color: '#3B82F6',
isDefault: true,
isVisible: true,
timezone: 'Europe/Berlin',
createdAt: '2026-01-01',
updatedAt: '2026-01-01',
},
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ calendars }),
});
const result = await calendarService.getCalendars();
expect(result.data).toEqual(calendars);
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/calendars'),
expect.any(Object)
);
});
});
describe('getCalendarEvents', () => {
it('should fetch events for specific calendar', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ events: [] }),
});
await calendarService.getCalendarEvents('cal-1', 14);
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('calendarIds=cal-1'),
expect.any(Object)
);
});
});
});

View file

@ -1,142 +0,0 @@
/**
* Calendar API Service
*
* Fetches events from the Calendar backend for dashboard widgets.
*/
import { browser } from '$app/environment';
import { createApiClient, type ApiResult } from '../base-client';
// Get Calendar API URL dynamically at runtime
function getCalendarApiUrl(): string {
if (browser && typeof window !== 'undefined') {
// Client-side: use injected window variable (set by hooks.server.ts)
const injectedUrl = (window as unknown as { __PUBLIC_CALENDAR_API_URL__?: string })
.__PUBLIC_CALENDAR_API_URL__;
if (injectedUrl) {
return `${injectedUrl}/api/v1`;
}
}
// Fallback for local development
return 'http://localhost:3016/api/v1';
}
// Lazy-initialized client to ensure we get the correct URL at runtime
let _client: ReturnType<typeof createApiClient> | null = null;
function getClient() {
if (!_client) {
_client = createApiClient(getCalendarApiUrl());
}
return _client;
}
/**
* Calendar entity from Calendar backend
*/
export interface Calendar {
id: string;
userId: string;
name: string;
description?: string;
color: string;
isDefault: boolean;
isVisible: boolean;
timezone: string;
createdAt: string;
updatedAt: string;
}
/**
* Event entity from Calendar backend
*/
export interface CalendarEvent {
id: string;
calendarId: string;
userId: string;
title: string;
description?: string;
location?: string;
startTime: string;
endTime: string;
isAllDay: boolean;
timezone: string;
recurrenceRule?: string;
color?: string;
status: 'confirmed' | 'tentative' | 'cancelled';
createdAt: string;
updatedAt: string;
}
/**
* Calendar service for dashboard widgets
*/
export const calendarService = {
/**
* Get upcoming events for the next N days
*/
async getUpcomingEvents(days: number = 7): Promise<ApiResult<CalendarEvent[]>> {
const startDate = new Date().toISOString().split('T')[0];
const endDate = new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const result = await getClient().get<{ events: CalendarEvent[] }>(
`/events?startDate=${startDate}&endDate=${endDate}`
);
if (result.error || !result.data) {
return { data: null, error: result.error };
}
return { data: result.data.events || [], error: null };
},
/**
* Get today's events
*/
async getTodayEvents(): Promise<ApiResult<CalendarEvent[]>> {
const today = new Date().toISOString().split('T')[0];
const result = await getClient().get<{ events: CalendarEvent[] }>(
`/events?startDate=${today}&endDate=${today}`
);
if (result.error || !result.data) {
return { data: null, error: result.error };
}
return { data: result.data.events || [], error: null };
},
/**
* Get all calendars
*/
async getCalendars(): Promise<ApiResult<Calendar[]>> {
const result = await getClient().get<{ calendars: Calendar[] }>('/calendars');
if (result.error || !result.data) {
return { data: null, error: result.error };
}
return { data: result.data.calendars || [], error: null };
},
/**
* Get events for a specific calendar
*/
async getCalendarEvents(
calendarId: string,
days: number = 7
): Promise<ApiResult<CalendarEvent[]>> {
const startDate = new Date().toISOString().split('T')[0];
const endDate = new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const result = await getClient().get<{ events: CalendarEvent[] }>(
`/events?calendarIds=${calendarId}&startDate=${startDate}&endDate=${endDate}`
);
if (result.error || !result.data) {
return { data: null, error: result.error };
}
return { data: result.data.events || [], error: null };
},
};

View file

@ -1,110 +0,0 @@
/**
* Cards API Service
*
* Fetches learning progress and deck data from the Cards backend for dashboard widgets.
*/
import { createApiClient, type ApiResult } from '../base-client';
// Backend URL - falls back to localhost for development
const CARDS_API_URL = import.meta.env.PUBLIC_CARDS_API_URL || 'http://localhost:3009/api/v1';
const client = createApiClient(CARDS_API_URL);
/**
* Deck entity from Cards backend
*/
export interface Deck {
id: string;
userId: string;
name: string;
description?: string;
cardCount: number;
dueCount: number;
newCount: number;
createdAt: string;
updatedAt: string;
lastStudied?: string;
}
/**
* Card entity from Cards backend
*/
export interface Card {
id: string;
deckId: string;
front: string;
back: string;
nextReview: string;
interval: number;
easeFactor: number;
repetitions: number;
createdAt: string;
updatedAt: string;
}
/**
* Learning progress statistics
*/
export interface LearningProgress {
totalCards: number;
cardsLearned: number;
cardsDueToday: number;
newCardsToday: number;
streakDays: number;
reviewsToday: number;
averageRetention: number;
}
/**
* Cards service for dashboard widgets
*/
export const cardsService = {
/**
* Get user's decks
*/
async getDecks(): Promise<ApiResult<Deck[]>> {
return client.get<Deck[]>('/decks');
},
/**
* Get learning progress
*/
async getLearningProgress(): Promise<ApiResult<LearningProgress>> {
return client.get<LearningProgress>('/progress');
},
/**
* Get cards due for review today
*/
async getDueCards(limit = 10): Promise<ApiResult<Card[]>> {
return client.get<Card[]>(`/cards/due?limit=${limit}`);
},
/**
* Get total due cards count across all decks
*/
async getTotalDueCount(): Promise<ApiResult<number>> {
const result = await this.getDecks();
if (result.error || !result.data) {
return { data: null, error: result.error };
}
const totalDue = result.data.reduce((sum, deck) => sum + deck.dueCount, 0);
return { data: totalDue, error: null };
},
/**
* Get study streak
*/
async getStreak(): Promise<ApiResult<number>> {
const result = await this.getLearningProgress();
if (result.error || !result.data) {
return { data: null, error: result.error };
}
return { data: result.data.streakDays, error: null };
},
};

View file

@ -1,145 +0,0 @@
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 { chatService, type Conversation } from './chat';
const mockConversation = (overrides: Partial<Conversation> = {}): Conversation => ({
id: 'conv-1',
userId: 'u-1',
title: 'Test Chat',
modelId: 'gpt-4',
conversationMode: 'free',
documentMode: false,
isArchived: false,
isPinned: false,
createdAt: '2026-01-01',
updatedAt: '2026-03-01',
...overrides,
});
describe('chatService', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('getRecentConversations', () => {
it('should fetch and sort conversations by updatedAt', async () => {
const conversations = [
mockConversation({ id: 'c-1', updatedAt: '2026-01-01' }),
mockConversation({ id: 'c-2', updatedAt: '2026-03-01' }),
mockConversation({ id: 'c-3', updatedAt: '2026-02-01' }),
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(conversations),
});
const result = await chatService.getRecentConversations(5);
expect(result.data).toHaveLength(3);
expect(result.data![0].id).toBe('c-2'); // Most recent first
expect(result.data![1].id).toBe('c-3');
expect(result.data![2].id).toBe('c-1');
});
it('should filter out archived conversations', async () => {
const conversations = [
mockConversation({ id: 'c-1', isArchived: false }),
mockConversation({ id: 'c-2', isArchived: true }),
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(conversations),
});
const result = await chatService.getRecentConversations();
expect(result.data).toHaveLength(1);
expect(result.data![0].id).toBe('c-1');
});
it('should respect limit parameter', async () => {
const conversations = Array.from({ length: 10 }, (_, i) =>
mockConversation({ id: `c-${i}`, updatedAt: `2026-03-${String(i + 1).padStart(2, '0')}` })
);
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(conversations),
});
const result = await chatService.getRecentConversations(3);
expect(result.data).toHaveLength(3);
});
});
describe('getPinnedConversations', () => {
it('should return only pinned non-archived conversations', async () => {
const conversations = [
mockConversation({ id: 'c-1', isPinned: true, isArchived: false }),
mockConversation({ id: 'c-2', isPinned: false }),
mockConversation({ id: 'c-3', isPinned: true, isArchived: true }),
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(conversations),
});
const result = await chatService.getPinnedConversations();
expect(result.data).toHaveLength(1);
expect(result.data![0].id).toBe('c-1');
});
});
describe('getConversationCount', () => {
it('should count active and pinned conversations', async () => {
const conversations = [
mockConversation({ isPinned: true, isArchived: false }),
mockConversation({ isPinned: false, isArchived: false }),
mockConversation({ isPinned: true, isArchived: true }),
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(conversations),
});
const result = await chatService.getConversationCount();
expect(result.data).toEqual({ total: 2, pinned: 1 });
});
});
describe('getModels', () => {
it('should fetch AI models', async () => {
const models = [{ id: 'm-1', name: 'GPT-4', description: 'Advanced model' }];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(models),
});
const result = await chatService.getModels();
expect(result.data).toEqual(models);
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/chat/models'),
expect.any(Object)
);
});
});
});

View file

@ -1,110 +0,0 @@
/**
* Chat API Service
*
* Fetches conversations from the Chat backend for dashboard widgets.
*/
import { createApiClient, type ApiResult } from '../base-client';
// Backend URL - falls back to localhost for development
const CHAT_API_URL = import.meta.env.PUBLIC_CHAT_API_URL || 'http://localhost:3002/api/v1';
const client = createApiClient(CHAT_API_URL);
/**
* Conversation entity from Chat backend
*/
export interface Conversation {
id: string;
userId: string;
title: string;
modelId: string;
spaceId?: string;
conversationMode: 'free' | 'guided' | 'template';
documentMode: boolean;
isArchived: boolean;
isPinned: boolean;
createdAt: string;
updatedAt: string;
}
/**
* Message entity from Chat backend
*/
export interface Message {
id: string;
conversationId: string;
sender: 'user' | 'assistant' | 'system';
messageText: string;
createdAt: string;
}
/**
* AI Model entity from Chat backend
*/
export interface AiModel {
id: string;
name: string;
description: string;
}
/**
* Chat service for dashboard widgets
*/
export const chatService = {
/**
* Get recent conversations
*/
async getRecentConversations(limit: number = 5): Promise<ApiResult<Conversation[]>> {
const result = await client.get<Conversation[]>('/conversations');
if (result.error || !result.data) {
return result;
}
// Sort by updatedAt and limit
const sorted = result.data
.filter((c) => !c.isArchived)
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
.slice(0, limit);
return { data: sorted, error: null };
},
/**
* Get pinned conversations
*/
async getPinnedConversations(): Promise<ApiResult<Conversation[]>> {
const result = await client.get<Conversation[]>('/conversations');
if (result.error || !result.data) {
return result;
}
const pinned = result.data.filter((c) => c.isPinned && !c.isArchived);
return { data: pinned, error: null };
},
/**
* Get available AI models
*/
async getModels(): Promise<ApiResult<AiModel[]>> {
return client.get<AiModel[]>('/chat/models');
},
/**
* Get conversation count
*/
async getConversationCount(): Promise<ApiResult<{ total: number; pinned: number }>> {
const result = await client.get<Conversation[]>('/conversations');
if (result.error || !result.data) {
return { data: null, error: result.error };
}
const active = result.data.filter((c) => !c.isArchived);
const pinned = active.filter((c) => c.isPinned);
return { data: { total: active.length, pinned: pinned.length }, error: null };
},
};

View file

@ -1,199 +0,0 @@
/**
* Clock API Service
*
* Fetches timers and alarms from local storage for dashboard widgets.
* Note: Clock app stores data in localStorage, not a backend.
*/
import type { ApiResult } from '../base-client';
/**
* Timer entity from Clock app
*/
export interface Timer {
id: string;
name: string;
duration: number; // Total duration in seconds
remaining: number; // Remaining time in seconds
isRunning: boolean;
isPaused: boolean;
createdAt: string;
completedAt?: string;
}
/**
* Alarm entity from Clock app
*/
export interface Alarm {
id: string;
name: string;
time: string; // HH:MM format
days: number[]; // 0-6 (Sunday to Saturday)
isEnabled: boolean;
sound?: string;
createdAt: string;
}
/**
* Pomodoro session from Clock app
*/
export interface PomodoroSession {
id: string;
type: 'work' | 'shortBreak' | 'longBreak';
duration: number;
completedAt: string;
}
/**
* Clock statistics
*/
export interface ClockStats {
activeTimers: number;
enabledAlarms: number;
pomodorosToday: number;
focusTimeToday: number; // In minutes
}
// LocalStorage keys (matching Clock app's storage)
const STORAGE_KEYS = {
timers: 'clock-timers',
alarms: 'clock-alarms',
pomodoros: 'clock-pomodoros',
};
/**
* Clock service for dashboard widgets
*
* Since Clock stores data in localStorage, this service reads from there.
*/
export const clockService = {
/**
* Get all timers
*/
async getTimers(): Promise<ApiResult<Timer[]>> {
try {
if (typeof window === 'undefined') {
return { data: [], error: null };
}
const stored = localStorage.getItem(STORAGE_KEYS.timers);
const timers = stored ? JSON.parse(stored) : [];
return { data: timers, error: null };
} catch {
return { data: null, error: 'Failed to load timers' };
}
},
/**
* Get active timers (running or paused with time remaining)
*/
async getActiveTimers(): Promise<ApiResult<Timer[]>> {
const result = await this.getTimers();
if (result.error || !result.data) {
return result;
}
const activeTimers = result.data.filter((t) => t.isRunning || (t.isPaused && t.remaining > 0));
return { data: activeTimers, error: null };
},
/**
* Get all alarms
*/
async getAlarms(): Promise<ApiResult<Alarm[]>> {
try {
if (typeof window === 'undefined') {
return { data: [], error: null };
}
const stored = localStorage.getItem(STORAGE_KEYS.alarms);
const alarms = stored ? JSON.parse(stored) : [];
return { data: alarms, error: null };
} catch {
return { data: null, error: 'Failed to load alarms' };
}
},
/**
* Get enabled alarms sorted by next trigger time
*/
async getEnabledAlarms(): Promise<ApiResult<Alarm[]>> {
const result = await this.getAlarms();
if (result.error || !result.data) {
return result;
}
const now = new Date();
const currentDay = now.getDay();
const currentTime = now.getHours() * 60 + now.getMinutes();
const enabledAlarms = result.data
.filter((a) => a.isEnabled)
.sort((a, b) => {
// Parse alarm times
const [aHours, aMinutes] = a.time.split(':').map(Number);
const [bHours, bMinutes] = b.time.split(':').map(Number);
const aTime = aHours * 60 + aMinutes;
const bTime = bHours * 60 + bMinutes;
// Sort by time of day
return aTime - bTime;
});
return { data: enabledAlarms, error: null };
},
/**
* Get clock statistics
*/
async getStats(): Promise<ApiResult<ClockStats>> {
try {
const [timersResult, alarmsResult] = await Promise.all([
this.getActiveTimers(),
this.getEnabledAlarms(),
]);
// Get pomodoro sessions for today
const pomodorosStored = localStorage.getItem(STORAGE_KEYS.pomodoros);
const pomodoros: PomodoroSession[] = pomodorosStored ? JSON.parse(pomodorosStored) : [];
const today = new Date().toDateString();
const todayPomodoros = pomodoros.filter(
(p) => new Date(p.completedAt).toDateString() === today
);
const focusTimeToday = todayPomodoros
.filter((p) => p.type === 'work')
.reduce((sum, p) => sum + p.duration, 0);
return {
data: {
activeTimers: timersResult.data?.length || 0,
enabledAlarms: alarmsResult.data?.length || 0,
pomodorosToday: todayPomodoros.filter((p) => p.type === 'work').length,
focusTimeToday: Math.round(focusTimeToday / 60),
},
error: null,
};
} catch {
return { data: null, error: 'Failed to load clock stats' };
}
},
/**
* Get next alarm time as a formatted string
*/
async getNextAlarmTime(): Promise<ApiResult<string | null>> {
const result = await this.getEnabledAlarms();
if (result.error || !result.data || result.data.length === 0) {
return { data: null, error: result.error };
}
// Get next alarm
const nextAlarm = result.data[0];
return { data: nextAlarm.time, error: null };
},
};

View file

@ -1,148 +0,0 @@
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 { contactsService, type Contact } from './contacts';
const mockContact = (overrides: Partial<Contact> = {}): Contact => ({
id: 'c-1',
userId: 'u-1',
firstName: 'Max',
lastName: 'Mustermann',
email: 'max@example.com',
isFavorite: false,
isArchived: false,
createdAt: '2026-01-01',
updatedAt: '2026-03-01',
...overrides,
});
describe('contactsService', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('getDisplayName', () => {
it('should return displayName when set', () => {
const contact = mockContact({ displayName: 'Dr. Max' });
expect(contactsService.getDisplayName(contact)).toBe('Dr. Max');
});
it('should return full name from firstName + lastName', () => {
const contact = mockContact({ displayName: undefined });
expect(contactsService.getDisplayName(contact)).toBe('Max Mustermann');
});
it('should return firstName only when lastName is missing', () => {
const contact = mockContact({ displayName: undefined, lastName: undefined });
expect(contactsService.getDisplayName(contact)).toBe('Max');
});
it('should return lastName only when firstName is missing', () => {
const contact = mockContact({
displayName: undefined,
firstName: undefined,
lastName: 'Mustermann',
});
expect(contactsService.getDisplayName(contact)).toBe('Mustermann');
});
it('should return email when no name is available', () => {
const contact = mockContact({
displayName: undefined,
firstName: undefined,
lastName: undefined,
email: 'max@example.com',
});
expect(contactsService.getDisplayName(contact)).toBe('max@example.com');
});
it('should return Unknown when nothing is available', () => {
const contact = mockContact({
displayName: undefined,
firstName: undefined,
lastName: undefined,
email: undefined,
});
expect(contactsService.getDisplayName(contact)).toBe('Unknown');
});
});
describe('getFavoriteContacts', () => {
it('should fetch favorite contacts', async () => {
const favorites = [mockContact({ isFavorite: true })];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ contacts: favorites, total: 1 }),
});
const result = await contactsService.getFavoriteContacts();
expect(result.data).toEqual(favorites);
expect(result.error).toBeNull();
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/contacts?isFavorite=true&limit=5'),
expect.any(Object)
);
});
it('should respect custom limit', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ contacts: [], total: 0 }),
});
await contactsService.getFavoriteContacts(10);
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('limit=10'),
expect.any(Object)
);
});
it('should return empty array when no contacts', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ contacts: [], total: 0 }),
});
const result = await contactsService.getFavoriteContacts();
expect(result.data).toEqual([]);
});
});
describe('getRecentContacts', () => {
it('should sort by updatedAt and filter archived', async () => {
const contacts = [
mockContact({ id: 'c-1', updatedAt: '2026-01-01', isArchived: false }),
mockContact({ id: 'c-2', updatedAt: '2026-03-01', isArchived: false }),
mockContact({ id: 'c-3', updatedAt: '2026-02-01', isArchived: true }),
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ contacts, total: 3 }),
});
const result = await contactsService.getRecentContacts(5);
expect(result.data).toHaveLength(2);
expect(result.data![0].id).toBe('c-2'); // Most recent first
expect(result.data![1].id).toBe('c-1');
});
});
});

View file

@ -1,174 +0,0 @@
/**
* Contacts API Service
*
* Fetches contacts from the Contacts backend for dashboard widgets.
*/
import { browser } from '$app/environment';
import { createApiClient, type ApiResult } from '../base-client';
// Get Contacts API URL dynamically at runtime
function getContactsApiUrl(): string {
if (browser && typeof window !== 'undefined') {
// Client-side: use injected window variable (set by hooks.server.ts)
const injectedUrl = (window as unknown as { __PUBLIC_CONTACTS_API_URL__?: string })
.__PUBLIC_CONTACTS_API_URL__;
if (injectedUrl) {
return `${injectedUrl}/api/v1`;
}
}
// Fallback for local development
return 'http://localhost:3015/api/v1';
}
// Lazy-initialized client to ensure we get the correct URL at runtime
let _client: ReturnType<typeof createApiClient> | null = null;
function getClient() {
if (!_client) {
_client = createApiClient(getContactsApiUrl());
}
return _client;
}
/**
* Contact entity from Contacts backend
*/
export interface Contact {
id: string;
userId: string;
firstName?: string;
lastName?: string;
displayName?: string;
nickname?: string;
email?: string;
phone?: string;
mobile?: string;
company?: string;
jobTitle?: string;
birthday?: string;
notes?: string;
isFavorite: boolean;
isArchived: boolean;
createdAt: string;
updatedAt: string;
}
/**
* Activity entity from Contacts backend
*/
export interface ContactActivity {
id: string;
contactId: string;
userId: string;
activityType: 'created' | 'updated' | 'called' | 'emailed' | 'met' | 'note_added';
description?: string;
metadata?: Record<string, unknown>;
createdAt: string;
}
/**
* API response format from Contacts backend
*/
interface ContactsApiResponse {
contacts: Contact[];
total: number;
}
/**
* Contacts service for dashboard widgets
*/
export const contactsService = {
/**
* Get favorite contacts
*/
async getFavoriteContacts(limit: number = 5): Promise<ApiResult<Contact[]>> {
const result = await getClient().get<ContactsApiResponse>(
`/contacts?isFavorite=true&limit=${limit}`
);
if (result.error || !result.data) {
return { data: null, error: result.error };
}
return { data: result.data.contacts || [], error: null };
},
/**
* Get recent contacts (by updatedAt)
*/
async getRecentContacts(limit: number = 5): Promise<ApiResult<Contact[]>> {
const result = await getClient().get<ContactsApiResponse>(`/contacts?limit=${limit}`);
if (result.error || !result.data) {
return { data: null, error: result.error };
}
// Sort by updatedAt and filter archived
const sorted = (result.data.contacts || [])
.filter((c) => !c.isArchived)
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
.slice(0, limit);
return { data: sorted, error: null };
},
/**
* Get contacts with upcoming birthdays
*/
async getUpcomingBirthdays(days: number = 30): Promise<ApiResult<Contact[]>> {
const result = await getClient().get<ContactsApiResponse>('/contacts');
if (result.error || !result.data) {
return { data: null, error: result.error };
}
const today = new Date();
const futureDate = new Date(Date.now() + days * 24 * 60 * 60 * 1000);
const withBirthdays = (result.data.contacts || []).filter((c) => {
if (!c.birthday || c.isArchived) return false;
const birthday = new Date(c.birthday);
// Set birthday to this year
birthday.setFullYear(today.getFullYear());
// If birthday already passed this year, check next year
if (birthday < today) {
birthday.setFullYear(today.getFullYear() + 1);
}
return birthday >= today && birthday <= futureDate;
});
return { data: withBirthdays, error: null };
},
/**
* Get contact count
*/
async getContactCount(): Promise<ApiResult<{ total: number; favorites: number }>> {
const result = await getClient().get<ContactsApiResponse>('/contacts');
if (result.error || !result.data) {
return { data: null, error: result.error };
}
const active = (result.data.contacts || []).filter((c) => !c.isArchived);
const favorites = active.filter((c) => c.isFavorite);
return { data: { total: active.length, favorites: favorites.length }, error: null };
},
/**
* Get display name for a contact
*/
getDisplayName(contact: Contact): string {
if (contact.displayName) return contact.displayName;
if (contact.firstName && contact.lastName) return `${contact.firstName} ${contact.lastName}`;
if (contact.firstName) return contact.firstName;
if (contact.lastName) return contact.lastName;
if (contact.email) return contact.email;
return 'Unknown';
},
};

View file

@ -1,127 +0,0 @@
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

@ -1,113 +0,0 @@
/**
* 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

@ -1,23 +1,20 @@
/**
* Dashboard API Services
* API Services barrel.
*
* Re-exports all app-specific services for the dashboard.
* Per-app HTTP backend services (todo, calendar, contacts, chat, storage,
* cards, music, picture, presi, zitare, clock, context) used to live here
* but were removed in the pre-launch ghost-API cleanup every product
* module now reads/writes the unified Dexie database via the local-first
* sync layer (`mana-sync`), and `qr-export` queries Dexie directly.
*
* What remains here is genuinely server-bound:
* - admin admin operations against mana-auth
* - landing org landing page editor (mana-landing-builder)
* - my-data user data summary / GDPR export against mana-auth
* - qr-export reads from local Dexie, builds the QR snapshot
*/
export { todoService, type Task, type Project, type Label, type Subtask } from './todo';
export { calendarService, type Calendar, type CalendarEvent } from './calendar';
export { chatService, type Conversation, type Message, type AiModel } from './chat';
export { contactsService, type Contact, type ContactActivity } from './contacts';
export { zitareService, type Favorite, type Quote, type QuoteList } from './zitare';
export { pictureService, type GeneratedImage, type GenerationStats } from './picture';
export { cardsService, type Deck, type Card, type LearningProgress } from './cards';
export { clockService, type Timer, type Alarm, type ClockStats } from './clock';
export { storageService, type StorageFile, type StorageStats } from './storage';
export { musicService, type Song, type MusicStats } from './music';
export { presiService, type PresiDeck, type PresiStats } from './presi';
export {
contextService,
type ContextSpace,
type ContextDocument,
type TokenBalance,
} from './context';
export { adminService, type UserListItem, type ProjectDataSummary } from './admin';
export { getOrganization, saveLandingConfig, publishLanding } from './landing';
export { myDataService, type UserDataSummary } from './my-data';
export { qrExportService, type QRExportData, type QRExportResult } from './qr-export';

View file

@ -1,92 +0,0 @@
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 { musicService } from './music';
describe('musicService', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('formatDuration', () => {
it('should format 0 seconds', () => {
expect(musicService.formatDuration(0)).toBe('0:00');
});
it('should format seconds only', () => {
expect(musicService.formatDuration(45)).toBe('0:45');
});
it('should format minutes and seconds', () => {
expect(musicService.formatDuration(185)).toBe('3:05');
});
it('should format hours', () => {
expect(musicService.formatDuration(3661)).toBe('1:01:01');
});
it('should pad seconds with zero', () => {
expect(musicService.formatDuration(60)).toBe('1:00');
});
it('should handle negative values', () => {
expect(musicService.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 musicService.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 musicService.getRecentSongs();
expect(result.data).toHaveLength(1);
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/songs?limit=5'),
expect.any(Object)
);
});
});
});

View file

@ -1,98 +0,0 @@
/**
* Music API Service
*
* Fetches music library stats from the Music backend for dashboard widgets.
*/
import { browser } from '$app/environment';
import { createApiClient, type ApiResult } from '../base-client';
// Get Music API URL dynamically at runtime
function getMusicApiUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_MUSIC_API_URL__?: string })
.__PUBLIC_MUSIC_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(getMusicApiUrl());
}
return _client;
}
/**
* Song entity from Music 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 MusicStats {
totalSongs: number;
totalPlaylists: number;
totalProjects: number;
favoriteCount: number;
totalPlayTime: number; // In seconds
}
/**
* Music service for dashboard widgets
*/
export const musicService = {
/**
* Get library statistics
*/
async getStats(): Promise<ApiResult<MusicStats>> {
return getClient().get<MusicStats>('/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

@ -1,81 +0,0 @@
/**
* Picture API Service
*
* Fetches recent AI-generated images from the Picture backend for dashboard widgets.
*/
import { createApiClient, type ApiResult } from '../base-client';
// Backend URL - falls back to localhost for development
const PICTURE_API_URL = import.meta.env.PUBLIC_PICTURE_API_URL || 'http://localhost:3006/api/v1';
const client = createApiClient(PICTURE_API_URL);
/**
* Generated image entity from Picture backend
*/
export interface GeneratedImage {
id: string;
userId: string;
prompt: string;
negativePrompt?: string;
imageUrl: string;
thumbnailUrl?: string;
width: number;
height: number;
model: string;
seed?: number;
steps?: number;
cfgScale?: number;
createdAt: string;
isFavorite?: boolean;
tags?: string[];
}
/**
* Generation statistics
*/
export interface GenerationStats {
totalGenerations: number;
thisMonth: number;
favoriteCount: number;
}
/**
* Picture service for dashboard widgets
*/
export const pictureService = {
/**
* Get user's recent generations
*/
async getRecentGenerations(limit = 6): Promise<ApiResult<GeneratedImage[]>> {
return client.get<GeneratedImage[]>(`/generations?limit=${limit}&sort=createdAt:desc`);
},
/**
* Get user's favorite images
*/
async getFavorites(limit = 6): Promise<ApiResult<GeneratedImage[]>> {
return client.get<GeneratedImage[]>(`/generations?favorite=true&limit=${limit}`);
},
/**
* Get generation statistics
*/
async getStats(): Promise<ApiResult<GenerationStats>> {
return client.get<GenerationStats>('/stats');
},
/**
* Get total generation count
*/
async getGenerationCount(): Promise<ApiResult<number>> {
const result = await this.getStats();
if (result.error || !result.data) {
return { data: null, error: result.error };
}
return { data: result.data.totalGenerations, error: null };
},
};

View file

@ -1,100 +0,0 @@
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

@ -1,101 +0,0 @@
/**
* 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

@ -1,125 +0,0 @@
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 { storageService, type StorageStats } from './storage';
describe('storageService', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('formatSize', () => {
it('should format 0 bytes', () => {
expect(storageService.formatSize(0)).toBe('0 B');
});
it('should format bytes', () => {
expect(storageService.formatSize(500)).toBe('500 B');
});
it('should format kilobytes', () => {
expect(storageService.formatSize(1024)).toBe('1 KB');
expect(storageService.formatSize(1536)).toBe('1.5 KB');
});
it('should format megabytes', () => {
expect(storageService.formatSize(1048576)).toBe('1 MB');
expect(storageService.formatSize(5242880)).toBe('5 MB');
});
it('should format gigabytes', () => {
expect(storageService.formatSize(1073741824)).toBe('1 GB');
});
it('should format terabytes', () => {
expect(storageService.formatSize(1099511627776)).toBe('1 TB');
});
it('should format with decimal precision', () => {
expect(storageService.formatSize(1500000)).toBe('1.43 MB');
});
});
describe('getStats', () => {
it('should fetch storage statistics', async () => {
const mockStats: StorageStats = {
totalFiles: 42,
totalSize: 104857600,
favoriteCount: 5,
recentFiles: [],
};
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockStats),
});
const result = await storageService.getStats();
expect(result.data).toEqual(mockStats);
expect(result.error).toBeNull();
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/files/stats'),
expect.any(Object)
);
});
it('should return error on failure', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 401,
});
const result = await storageService.getStats();
expect(result.data).toBeNull();
expect(result.error).toBeTruthy();
});
});
describe('getRecentFiles', () => {
it('should return limited recent files from stats', async () => {
const files = Array.from({ length: 10 }, (_, i) => ({
id: `f-${i}`,
name: `file-${i}.txt`,
originalName: `file-${i}.txt`,
mimeType: 'text/plain',
size: 1024,
storagePath: `/files/f-${i}`,
storageKey: `f-${i}`,
isFavorite: false,
currentVersion: 1,
createdAt: '2026-01-01',
updatedAt: '2026-01-01',
}));
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
totalFiles: 10,
totalSize: 10240,
favoriteCount: 0,
recentFiles: files,
}),
});
const result = await storageService.getRecentFiles(3);
expect(result.data).toHaveLength(3);
});
});
});

View file

@ -1,102 +0,0 @@
/**
* Storage API Service
*
* Fetches file storage stats from the Storage backend for dashboard widgets.
*/
import { browser } from '$app/environment';
import { createApiClient, type ApiResult } from '../base-client';
// Get Storage API URL dynamically at runtime
function getStorageApiUrl(): string {
if (browser && typeof window !== 'undefined') {
// Client-side: use injected window variable (set by hooks.server.ts)
const injectedUrl = (window as unknown as { __PUBLIC_STORAGE_API_URL__?: string })
.__PUBLIC_STORAGE_API_URL__;
if (injectedUrl) {
return `${injectedUrl}/api/v1`;
}
}
// Fallback for local development
return 'http://localhost:3016/api/v1';
}
// Lazy-initialized client to ensure we get the correct URL at runtime
let _client: ReturnType<typeof createApiClient> | null = null;
function getClient() {
if (!_client) {
_client = createApiClient(getStorageApiUrl());
}
return _client;
}
/**
* File entity from Storage backend
*/
export interface StorageFile {
id: string;
name: string;
originalName: string;
mimeType: string;
size: number;
storagePath: string;
storageKey: string;
parentFolderId?: string | null;
isFavorite: boolean;
currentVersion: number;
createdAt: string;
updatedAt: string;
}
/**
* Storage stats from backend
*/
export interface StorageStats {
totalFiles: number;
totalSize: number;
favoriteCount: number;
recentFiles: StorageFile[];
}
/**
* Storage service for dashboard widgets
*/
export const storageService = {
/**
* Get storage statistics
*/
async getStats(): Promise<ApiResult<StorageStats>> {
const result = await getClient().get<StorageStats>('/files/stats');
if (result.error || !result.data) {
return { data: null, error: result.error };
}
return { data: result.data, error: null };
},
/**
* Get recent files
*/
async getRecentFiles(limit: number = 5): Promise<ApiResult<StorageFile[]>> {
const result = await this.getStats();
if (result.error || !result.data) {
return { data: null, error: result.error };
}
return { data: result.data.recentFiles.slice(0, limit), error: null };
},
/**
* Format file size for display
*/
formatSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
},
};

View file

@ -1,142 +0,0 @@
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 { todoService, type Task } from './todo';
const mockTask = (overrides: Partial<Task> = {}): Task => ({
id: 't-1',
title: 'Test Task',
priority: 'medium',
isCompleted: false,
status: 'pending',
labels: [],
subtasks: null,
createdAt: '2026-01-01',
updatedAt: '2026-03-01',
...overrides,
});
describe('todoService', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('getTodayTasks', () => {
it('should fetch today tasks', async () => {
const tasks = [mockTask(), mockTask({ id: 't-2', title: 'Task 2' })];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ tasks }),
});
const result = await todoService.getTodayTasks();
expect(result.data).toEqual(tasks);
expect(result.error).toBeNull();
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/tasks/today'),
expect.any(Object)
);
});
it('should return empty array when no tasks', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ tasks: [] }),
});
const result = await todoService.getTodayTasks();
expect(result.data).toEqual([]);
});
it('should return error on failure', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 401,
});
const result = await todoService.getTodayTasks();
expect(result.data).toBeNull();
expect(result.error).toBeTruthy();
});
});
describe('getUpcomingTasks', () => {
it('should fetch upcoming tasks with default 7 days', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ tasks: [mockTask()] }),
});
const result = await todoService.getUpcomingTasks();
expect(result.data).toHaveLength(1);
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/tasks/upcoming?days=7'),
expect.any(Object)
);
});
it('should pass custom days parameter', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ tasks: [] }),
});
await todoService.getUpcomingTasks(14);
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('days=14'),
expect.any(Object)
);
});
});
describe('getInboxTasks', () => {
it('should fetch inbox tasks', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ tasks: [mockTask({ projectId: null })] }),
});
const result = await todoService.getInboxTasks();
expect(result.data).toHaveLength(1);
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/tasks/inbox'),
expect.any(Object)
);
});
});
describe('getProjects', () => {
it('should fetch projects', async () => {
const projects = [{ id: 'p-1', name: 'Work', color: '#3B82F6', order: 0, isArchived: false }];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ projects }),
});
const result = await todoService.getProjects();
expect(result.data).toEqual(projects);
});
});
});

View file

@ -1,208 +0,0 @@
/**
* Todo API Service
*
* Fetches tasks from the Todo backend for dashboard widgets.
*/
import { browser } from '$app/environment';
import { createApiClient, type ApiResult } from '../base-client';
// Get Todo API URL dynamically at runtime
function getTodoApiUrl(): string {
if (browser && typeof window !== 'undefined') {
// Client-side: use injected window variable (set by hooks.server.ts)
const injectedUrl = (window as unknown as { __PUBLIC_TODO_API_URL__?: string })
.__PUBLIC_TODO_API_URL__;
if (injectedUrl) {
return `${injectedUrl}/api/v1`;
}
}
// Fallback for local development
return 'http://localhost:3018/api/v1';
}
// Lazy-initialized client to ensure we get the correct URL at runtime
let _client: ReturnType<typeof createApiClient> | null = null;
function getClient() {
if (!_client) {
_client = createApiClient(getTodoApiUrl());
}
return _client;
}
/**
* Label entity from Todo backend
*/
export interface Label {
id: string;
name: string;
color: string;
}
/**
* Subtask entity from Todo backend
*/
export interface Subtask {
id: string;
title: string;
isCompleted: boolean;
}
/**
* Task entity from Todo backend
*/
export interface Task {
id: string;
title: string;
description?: string;
projectId?: string | null;
priority: 'low' | 'medium' | 'high' | 'urgent';
dueDate?: string;
dueTime?: string;
isCompleted: boolean;
status: 'pending' | 'in_progress' | 'completed' | 'cancelled';
labels?: Label[];
subtasks?: Subtask[] | null;
createdAt: string;
updatedAt: string;
}
/**
* Project entity from Todo backend
*/
export interface Project {
id: string;
name: string;
color: string;
icon?: string;
order: number;
isArchived: boolean;
}
/**
* Todo service for dashboard widgets
*/
export const todoService = {
/**
* Get today's tasks
*/
async getTodayTasks(): Promise<ApiResult<Task[]>> {
const result = await getClient().get<{ tasks: Task[] }>('/tasks/today');
if (result.error || !result.data) {
return { data: null, error: result.error };
}
return { data: result.data.tasks || [], error: null };
},
/**
* Get all open tasks sorted by due date (today first, then future, then no date)
*/
async getAllOpenTasks(): Promise<ApiResult<Task[]>> {
const result = await getClient().get<{ tasks: Task[] }>('/tasks');
if (result.error || !result.data) {
return { data: null, error: result.error };
}
const openTasks = (result.data.tasks || []).filter((t) => !t.isCompleted);
// Sort: today/overdue first, then by date ascending, tasks without date last
const now = new Date();
now.setHours(0, 0, 0, 0);
openTasks.sort((a, b) => {
const dateA = a.dueDate ? new Date(a.dueDate).getTime() : Infinity;
const dateB = b.dueDate ? new Date(b.dueDate).getTime() : Infinity;
return dateA - dateB;
});
return { data: openTasks, error: null };
},
/**
* Get upcoming tasks for the next N days
*/
async getUpcomingTasks(days: number = 7): Promise<ApiResult<Task[]>> {
const result = await getClient().get<{ tasks: Task[] }>(`/tasks/upcoming?days=${days}`);
if (result.error || !result.data) {
return { data: null, error: result.error };
}
return { data: result.data.tasks || [], error: null };
},
/**
* Get inbox tasks (unassigned to project)
*/
async getInboxTasks(): Promise<ApiResult<Task[]>> {
const result = await getClient().get<{ tasks: Task[] }>('/tasks/inbox');
if (result.error || !result.data) {
return { data: null, error: result.error };
}
return { data: result.data.tasks || [], error: null };
},
/**
* Get all projects
*/
async getProjects(): Promise<ApiResult<Project[]>> {
const result = await getClient().get<{ projects: Project[] }>('/projects');
if (result.error || !result.data) {
return { data: null, error: result.error };
}
return { data: result.data.projects || [], error: null };
},
/**
* Mark a task as complete
*/
async completeTask(id: string): Promise<ApiResult<Task>> {
const result = await getClient().post<{ task: Task }>(`/tasks/${id}/complete`);
if (result.error || !result.data) {
return { data: null, error: result.error };
}
return { data: result.data.task, error: null };
},
/**
* Mark a task as incomplete
*/
async uncompleteTask(id: string): Promise<ApiResult<Task>> {
const result = await getClient().post<{ task: Task }>(`/tasks/${id}/uncomplete`);
if (result.error || !result.data) {
return { data: null, error: result.error };
}
return { data: result.data.task, error: null };
},
/**
* Get task count summary
*/
async getTaskCounts(): Promise<ApiResult<{ today: number; upcoming: number; overdue: number }>> {
// This might need a dedicated endpoint - for now we fetch and count
const todayResult = await this.getTodayTasks();
const upcomingResult = await this.getUpcomingTasks();
if (todayResult.error || upcomingResult.error) {
return { data: null, error: todayResult.error || upcomingResult.error };
}
const today = todayResult.data?.length || 0;
const upcoming = upcomingResult.data?.length || 0;
const overdue =
todayResult.data?.filter((t) => t.dueDate && new Date(t.dueDate) < new Date()).length || 0;
return { data: { today, upcoming, overdue }, error: null };
},
};

View file

@ -1,132 +0,0 @@
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 { zitareService, type Favorite } from './zitare';
describe('zitareService', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('getFavorites', () => {
it('should fetch favorites', async () => {
const favorites: Favorite[] = [
{ id: 'f-1', userId: 'u-1', quoteId: 'q-1', createdAt: '2026-01-01' },
{ id: 'f-2', userId: 'u-1', quoteId: 'q-2', createdAt: '2026-02-01' },
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(favorites),
});
const result = await zitareService.getFavorites();
expect(result.data).toEqual(favorites);
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/favorites'),
expect.any(Object)
);
});
});
describe('getRandomFavorite', () => {
it('should return a random favorite from the list', async () => {
const favorites: Favorite[] = [
{ id: 'f-1', userId: 'u-1', quoteId: 'q-1', createdAt: '2026-01-01' },
{ id: 'f-2', userId: 'u-1', quoteId: 'q-2', createdAt: '2026-02-01' },
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(favorites),
});
const result = await zitareService.getRandomFavorite();
expect(result.data).toBeTruthy();
expect(result.error).toBeNull();
expect(favorites.map((f) => f.id)).toContain(result.data!.id);
});
it('should return error when no favorites exist', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve([]),
});
const result = await zitareService.getRandomFavorite();
expect(result.data).toBeNull();
expect(result.error).toBe('No favorites found');
});
});
describe('getFavoriteCount', () => {
it('should return the count of favorites', async () => {
const favorites = [
{ id: 'f-1', userId: 'u-1', quoteId: 'q-1', createdAt: '2026-01-01' },
{ id: 'f-2', userId: 'u-1', quoteId: 'q-2', createdAt: '2026-02-01' },
{ id: 'f-3', userId: 'u-1', quoteId: 'q-3', createdAt: '2026-03-01' },
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(favorites),
});
const result = await zitareService.getFavoriteCount();
expect(result.data).toBe(3);
expect(result.error).toBeNull();
});
it('should return error on network failure', async () => {
global.fetch = vi.fn().mockRejectedValue(new Error('Failed to fetch'));
const result = await zitareService.getFavoriteCount();
expect(result.data).toBeNull();
expect(result.error).toBeTruthy();
});
});
describe('getLists', () => {
it('should fetch quote lists', async () => {
const lists = [
{
id: 'l-1',
userId: 'u-1',
name: 'Motivation',
quoteIds: ['q-1', 'q-2'],
createdAt: '2026-01-01',
updatedAt: '2026-01-01',
},
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(lists),
});
const result = await zitareService.getLists();
expect(result.data).toEqual(lists);
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/lists'),
expect.any(Object)
);
});
});
});

View file

@ -1,92 +0,0 @@
/**
* Zitare API Service
*
* Fetches favorite quotes from the Zitare backend for dashboard widgets.
*/
import { createApiClient, type ApiResult } from '../base-client';
// Backend URL - falls back to localhost for development
const ZITARE_API_URL = import.meta.env.PUBLIC_ZITARE_API_URL || 'http://localhost:3007/api/v1';
const client = createApiClient(ZITARE_API_URL);
/**
* Favorite entity from Zitare backend
*/
export interface Favorite {
id: string;
userId: string;
quoteId: string;
createdAt: string;
}
/**
* Quote data (may need to be enriched from a quotes API)
*/
export interface Quote {
id: string;
text: string;
author?: string;
source?: string;
category?: string;
}
/**
* List entity from Zitare backend
*/
export interface QuoteList {
id: string;
userId: string;
name: string;
description?: string;
quoteIds: string[];
createdAt: string;
updatedAt: string;
}
/**
* Zitare service for dashboard widgets
*/
export const zitareService = {
/**
* Get user's favorite quotes
*/
async getFavorites(): Promise<ApiResult<Favorite[]>> {
return client.get<Favorite[]>('/favorites');
},
/**
* Get a random favorite quote
*/
async getRandomFavorite(): Promise<ApiResult<Favorite | null>> {
const result = await this.getFavorites();
if (result.error || !result.data || result.data.length === 0) {
return { data: null, error: result.error || 'No favorites found' };
}
const randomIndex = Math.floor(Math.random() * result.data.length);
return { data: result.data[randomIndex], error: null };
},
/**
* Get user's quote lists
*/
async getLists(): Promise<ApiResult<QuoteList[]>> {
return client.get<QuoteList[]>('/lists');
},
/**
* Get favorite count
*/
async getFavoriteCount(): Promise<ApiResult<number>> {
const result = await this.getFavorites();
if (result.error || !result.data) {
return { data: null, error: result.error };
}
return { data: result.data.length, error: null };
},
};

View file

@ -8,18 +8,14 @@ interface ServiceStatus {
details?: string;
}
// The per-app HTTP backends (todo, calendar, contacts, chat, storage,
// cards, music, nutriphi, picture, presi, zitare, clock, context) were
// removed in the pre-launch ghost-API cleanup — every product module now
// reads/writes the unified Dexie database via mana-sync.
const SERVICES = [
{ name: 'Auth', url: process.env.PUBLIC_MANA_AUTH_URL || 'http://localhost:3001' },
{ name: 'Todo API', url: process.env.PUBLIC_TODO_API_URL || 'http://localhost:3031' },
{ name: 'Calendar API', url: process.env.PUBLIC_CALENDAR_API_URL || 'http://localhost:3032' },
{ name: 'Contacts API', url: process.env.PUBLIC_CONTACTS_API_URL || 'http://localhost:3033' },
{ name: 'Chat API', url: process.env.PUBLIC_CHAT_API_URL || 'http://localhost:3030' },
{ name: 'Storage API', url: process.env.PUBLIC_STORAGE_API_URL || 'http://localhost:3034' },
{ name: 'Cards API', url: process.env.PUBLIC_CARDS_API_URL || 'http://localhost:3036' },
{ name: 'Music API', url: process.env.PUBLIC_MUSIC_API_URL || 'http://localhost:3037' },
{ name: 'NutriPhi API', url: process.env.PUBLIC_NUTRIPHI_API_URL || 'http://localhost:3038' },
{ name: 'Sync', url: process.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3010' },
{ name: 'Uload Server', url: process.env.PUBLIC_ULOAD_SERVER_URL || 'http://localhost:3070' },
{ name: 'Memoro Server', url: process.env.PUBLIC_MEMORO_SERVER_URL || 'http://localhost:3015' },
{ name: 'Media', url: process.env.PUBLIC_MANA_MEDIA_URL || 'http://localhost:3011' },
{ name: 'LLM', url: process.env.PUBLIC_MANA_LLM_URL || 'http://localhost:3025' },
];