mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 23:41:08 +02:00
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:
parent
c27cb84f28
commit
3a473897ec
24 changed files with 42 additions and 2758 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
},
|
||||
};
|
||||
|
|
@ -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 };
|
||||
},
|
||||
};
|
||||
|
|
@ -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)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
},
|
||||
};
|
||||
|
|
@ -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 };
|
||||
},
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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';
|
||||
},
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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')}`;
|
||||
},
|
||||
};
|
||||
|
|
@ -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 };
|
||||
},
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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];
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
},
|
||||
};
|
||||
|
|
@ -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)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
},
|
||||
};
|
||||
|
|
@ -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' },
|
||||
];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue