dashboard
This commit is contained in:
@@ -15,9 +15,13 @@ JWT_REFRESH_SECRET=change-me-refresh-secret
|
|||||||
|
|
||||||
CLIENT_URL=http://localhost:4200
|
CLIENT_URL=http://localhost:4200
|
||||||
|
|
||||||
|
MCP_ACCESS_TOKEN=
|
||||||
|
|
||||||
MISTRAL_API_KEY=
|
MISTRAL_API_KEY=
|
||||||
MISTRAL_AGENT_ID=
|
MISTRAL_AGENT_ID=
|
||||||
|
|
||||||
|
DASHBOARD_TIMEZONE=Europe/Budapest
|
||||||
|
|
||||||
MAIL_ENABLED=true
|
MAIL_ENABLED=true
|
||||||
SMTP_HOST=localhost
|
SMTP_HOST=localhost
|
||||||
SMTP_PORT=1025
|
SMTP_PORT=1025
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { AppService } from './app.service';
|
|||||||
import { AssistantModule } from './assistant/assistant.module';
|
import { AssistantModule } from './assistant/assistant.module';
|
||||||
import { AuditModule } from './audit/audit.module';
|
import { AuditModule } from './audit/audit.module';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
|
import { DashboardModule } from './dashboard/dashboard.module';
|
||||||
import { ListTemplatesModule } from './list-templates/list-templates.module';
|
import { ListTemplatesModule } from './list-templates/list-templates.module';
|
||||||
import { ListsModule } from './lists/lists.module';
|
import { ListsModule } from './lists/lists.module';
|
||||||
import { MailModule } from './mail/mail.module';
|
import { MailModule } from './mail/mail.module';
|
||||||
@@ -26,7 +27,10 @@ import { DatabaseLogger } from './database/database.logger';
|
|||||||
useFactory: (configService: ConfigService) => {
|
useFactory: (configService: ConfigService) => {
|
||||||
const env = {
|
const env = {
|
||||||
DB_LOGGING: configService.get<string>('DB_LOGGING', 'false'),
|
DB_LOGGING: configService.get<string>('DB_LOGGING', 'false'),
|
||||||
DB_LOG_PARAMETERS: configService.get<string>('DB_LOG_PARAMETERS', 'false'),
|
DB_LOG_PARAMETERS: configService.get<string>(
|
||||||
|
'DB_LOG_PARAMETERS',
|
||||||
|
'false',
|
||||||
|
),
|
||||||
DB_LOG_MAX_PARAM_LENGTH: configService.get<string>(
|
DB_LOG_MAX_PARAM_LENGTH: configService.get<string>(
|
||||||
'DB_LOG_MAX_PARAM_LENGTH',
|
'DB_LOG_MAX_PARAM_LENGTH',
|
||||||
'500',
|
'500',
|
||||||
@@ -56,6 +60,7 @@ import { DatabaseLogger } from './database/database.logger';
|
|||||||
AssistantModule,
|
AssistantModule,
|
||||||
AuditModule,
|
AuditModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
DashboardModule,
|
||||||
MailModule,
|
MailModule,
|
||||||
ListsModule,
|
ListsModule,
|
||||||
ListTemplatesModule,
|
ListTemplatesModule,
|
||||||
|
|||||||
56
listify-api/src/dashboard/daily-dashboard-snapshot.entity.ts
Normal file
56
listify-api/src/dashboard/daily-dashboard-snapshot.entity.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
PrimaryColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('daily_dashboard_snapshots')
|
||||||
|
@Index(['userId', 'dateKey'], { unique: true })
|
||||||
|
export class DailyDashboardSnapshotEntity {
|
||||||
|
@PrimaryColumn({ type: 'varchar', length: 36 })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ type: 'varchar', length: 36 })
|
||||||
|
userId!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 10 })
|
||||||
|
dateKey!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 80 })
|
||||||
|
timezone!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'json' })
|
||||||
|
selectedLists!: Array<{
|
||||||
|
listId: string;
|
||||||
|
reason: string;
|
||||||
|
source: 'ai' | 'fallback';
|
||||||
|
}>;
|
||||||
|
|
||||||
|
@Column({ type: 'json' })
|
||||||
|
requestPayload!: Record<string, unknown>;
|
||||||
|
|
||||||
|
@Column({ type: 'json', nullable: true })
|
||||||
|
responsePayload?: unknown;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
errorMessage?: string | null;
|
||||||
|
|
||||||
|
@CreateDateColumn({
|
||||||
|
type: 'datetime',
|
||||||
|
precision: 3,
|
||||||
|
default: () => 'CURRENT_TIMESTAMP(3)',
|
||||||
|
})
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({
|
||||||
|
type: 'datetime',
|
||||||
|
precision: 3,
|
||||||
|
default: () => 'CURRENT_TIMESTAMP(3)',
|
||||||
|
onUpdate: 'CURRENT_TIMESTAMP(3)',
|
||||||
|
})
|
||||||
|
updatedAt!: Date;
|
||||||
|
}
|
||||||
42
listify-api/src/dashboard/dashboard.controller.ts
Normal file
42
listify-api/src/dashboard/dashboard.controller.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
UnauthorizedException,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import type { AuthenticatedRequest } from '../auth/auth.types';
|
||||||
|
import { DashboardService } from './dashboard.service';
|
||||||
|
|
||||||
|
@Controller('dashboard')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class DashboardController {
|
||||||
|
constructor(private readonly dashboardService: DashboardService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
getDashboard(@Req() request: AuthenticatedRequest) {
|
||||||
|
return this.dashboardService.getDashboard(this.requireUserId(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('weekly-suggestions/:suggestionId/create')
|
||||||
|
createSuggestion(
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@Param('suggestionId') suggestionId: string,
|
||||||
|
) {
|
||||||
|
return this.dashboardService.createSuggestionList(
|
||||||
|
this.requireUserId(request),
|
||||||
|
suggestionId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireUserId(request: AuthenticatedRequest): string {
|
||||||
|
if (!request.user?.sub) {
|
||||||
|
throw new UnauthorizedException('Authenticated user is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return request.user.sub;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
listify-api/src/dashboard/dashboard.module.ts
Normal file
20
listify-api/src/dashboard/dashboard.module.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { ListsModule } from '../lists/lists.module';
|
||||||
|
import { DailyDashboardSnapshotEntity } from './daily-dashboard-snapshot.entity';
|
||||||
|
import { DashboardController } from './dashboard.controller';
|
||||||
|
import { DashboardService } from './dashboard.service';
|
||||||
|
import { WeeklyListSuggestionSnapshotEntity } from './weekly-list-suggestion-snapshot.entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ListsModule,
|
||||||
|
TypeOrmModule.forFeature([
|
||||||
|
DailyDashboardSnapshotEntity,
|
||||||
|
WeeklyListSuggestionSnapshotEntity,
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
controllers: [DashboardController],
|
||||||
|
providers: [DashboardService],
|
||||||
|
})
|
||||||
|
export class DashboardModule {}
|
||||||
391
listify-api/src/dashboard/dashboard.service.spec.ts
Normal file
391
listify-api/src/dashboard/dashboard.service.spec.ts
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { InMemoryRepository } from '../testing/in-memory-repository';
|
||||||
|
import { UserList } from '../list-templates/list-template.types';
|
||||||
|
import { ListsService } from '../lists/lists.service';
|
||||||
|
import { DailyDashboardSnapshotEntity } from './daily-dashboard-snapshot.entity';
|
||||||
|
import { DashboardService } from './dashboard.service';
|
||||||
|
import { WeeklyListSuggestionSnapshotEntity } from './weekly-list-suggestion-snapshot.entity';
|
||||||
|
|
||||||
|
describe('DashboardService', () => {
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
const originalApiKey = process.env.MISTRAL_API_KEY;
|
||||||
|
const originalModel = process.env.MISTRAL_MODEL;
|
||||||
|
const originalTimezone = process.env.DASHBOARD_TIMEZONE;
|
||||||
|
let dailySnapshotsRepository: InMemoryRepository<DailyDashboardSnapshotEntity>;
|
||||||
|
let weeklySnapshotsRepository: InMemoryRepository<WeeklyListSuggestionSnapshotEntity>;
|
||||||
|
let listsService: {
|
||||||
|
listLists: jest.Mock;
|
||||||
|
createList: jest.Mock;
|
||||||
|
addItem: jest.Mock;
|
||||||
|
};
|
||||||
|
let service: DashboardService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.MISTRAL_API_KEY = 'test-key';
|
||||||
|
process.env.MISTRAL_MODEL = 'mistral-large-test';
|
||||||
|
process.env.DASHBOARD_TIMEZONE = 'UTC';
|
||||||
|
global.fetch = jest.fn();
|
||||||
|
dailySnapshotsRepository =
|
||||||
|
new InMemoryRepository<DailyDashboardSnapshotEntity>();
|
||||||
|
weeklySnapshotsRepository =
|
||||||
|
new InMemoryRepository<WeeklyListSuggestionSnapshotEntity>();
|
||||||
|
listsService = {
|
||||||
|
listLists: jest.fn(),
|
||||||
|
createList: jest.fn(),
|
||||||
|
addItem: jest.fn(),
|
||||||
|
};
|
||||||
|
service = new DashboardService(
|
||||||
|
dailySnapshotsRepository as never,
|
||||||
|
weeklySnapshotsRepository as never,
|
||||||
|
listsService as unknown as ListsService,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
restoreEnv('MISTRAL_API_KEY', originalApiKey);
|
||||||
|
restoreEnv('MISTRAL_MODEL', originalModel);
|
||||||
|
restoreEnv('DASHBOARD_TIMEZONE', originalTimezone);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a daily snapshot once and reuses the database cache', async () => {
|
||||||
|
const lists = [
|
||||||
|
createList('list-1', 'Einkauf'),
|
||||||
|
createList('list-2', 'Todo'),
|
||||||
|
];
|
||||||
|
listsService.listLists.mockResolvedValue(lists);
|
||||||
|
await saveCurrentWeeklySnapshot(weeklySnapshotsRepository, 'user-1');
|
||||||
|
mockMistralResponses([
|
||||||
|
{
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
content: JSON.stringify({
|
||||||
|
lists: [
|
||||||
|
{ listId: 'list-2', reason: 'Viele offene Aufgaben.' },
|
||||||
|
{ listId: 'list-1', reason: 'Aktueller Einkauf.' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const firstResponse = await service.getDashboard('user-1');
|
||||||
|
const secondResponse = await service.getDashboard('user-1');
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(await dailySnapshotsRepository.find()).toHaveLength(1);
|
||||||
|
expect(firstResponse.importantLists.map((item) => item.list.id)).toEqual([
|
||||||
|
'list-2',
|
||||||
|
'list-1',
|
||||||
|
]);
|
||||||
|
expect(secondResponse.importantLists.map((item) => item.list.id)).toEqual([
|
||||||
|
'list-2',
|
||||||
|
'list-1',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates weekly suggestions once per user and week', async () => {
|
||||||
|
listsService.listLists.mockResolvedValue([createList('list-1', 'Einkauf')]);
|
||||||
|
mockMistralResponses([
|
||||||
|
{
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
content: '{"lists":[{"listId":"list-1","reason":"Relevant."}]}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
content: JSON.stringify({
|
||||||
|
suggestions: [
|
||||||
|
{
|
||||||
|
title: 'Meal Prep',
|
||||||
|
description: 'Vorbereitung fuer die Woche',
|
||||||
|
items: [{ title: 'Rezepte auswaehlen', required: true }],
|
||||||
|
reason: 'Ergaenzt Einkaufslisten.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const firstResponse = await service.getDashboard('user-1');
|
||||||
|
const secondResponse = await service.getDashboard('user-1');
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||||
|
expect(await weeklySnapshotsRepository.find()).toHaveLength(1);
|
||||||
|
expect(firstResponse.weeklySuggestions.suggestions).toHaveLength(1);
|
||||||
|
expect(secondResponse.weeklySuggestions.suggestions).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps dashboard snapshots isolated per user', async () => {
|
||||||
|
listsService.listLists.mockImplementation((userId: string) =>
|
||||||
|
Promise.resolve([createList(`${userId}-list`, `Liste ${userId}`)]),
|
||||||
|
);
|
||||||
|
mockMistralResponses([
|
||||||
|
{
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
content: '{"lists":[{"listId":"user-1-list","reason":"User 1"}]}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ choices: [{ message: { content: '{"suggestions":[]}' } }] },
|
||||||
|
{
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
content: '{"lists":[{"listId":"user-2-list","reason":"User 2"}]}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ choices: [{ message: { content: '{"suggestions":[]}' } }] },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const userOneDashboard = await service.getDashboard('user-1');
|
||||||
|
const userTwoDashboard = await service.getDashboard('user-2');
|
||||||
|
|
||||||
|
expect(userOneDashboard.importantLists[0].list.id).toBe('user-1-list');
|
||||||
|
expect(userTwoDashboard.importantLists[0].list.id).toBe('user-2-list');
|
||||||
|
expect(await dailySnapshotsRepository.find()).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters deleted lists from a stored daily snapshot at response time', async () => {
|
||||||
|
const { dateKey } = currentKeys();
|
||||||
|
listsService.listLists.mockResolvedValue([createList('list-2', 'Bleibt')]);
|
||||||
|
await dailySnapshotsRepository.save({
|
||||||
|
id: 'daily-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
dateKey,
|
||||||
|
timezone: 'UTC',
|
||||||
|
selectedLists: [
|
||||||
|
{ listId: 'list-1', reason: 'Geloescht', source: 'ai' },
|
||||||
|
{ listId: 'list-2', reason: 'Bleibt sichtbar', source: 'ai' },
|
||||||
|
],
|
||||||
|
requestPayload: {},
|
||||||
|
responsePayload: null,
|
||||||
|
errorMessage: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
await saveCurrentWeeklySnapshot(weeklySnapshotsRepository, 'user-1');
|
||||||
|
|
||||||
|
const response = await service.getDashboard('user-1');
|
||||||
|
|
||||||
|
expect(response.importantLists).toHaveLength(1);
|
||||||
|
expect(response.importantLists[0].list.id).toBe('list-2');
|
||||||
|
expect(global.fetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back deterministically when the daily AI call fails', async () => {
|
||||||
|
const lists = [
|
||||||
|
createList('old', 'Alt', { updatedAt: '2026-01-01T00:00:00.000Z' }),
|
||||||
|
createList('open', 'Offen', {
|
||||||
|
items: [
|
||||||
|
{ id: 'item-1', title: 'A', checked: false },
|
||||||
|
{ id: 'item-2', title: 'B', checked: false },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
createList('reminder', 'Reminder', {
|
||||||
|
reminderAt: '2026-06-24T10:00:00.000Z',
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
listsService.listLists.mockResolvedValue(lists);
|
||||||
|
await saveCurrentWeeklySnapshot(weeklySnapshotsRepository, 'user-1');
|
||||||
|
jest.mocked(global.fetch).mockRejectedValue(new Error('network down'));
|
||||||
|
|
||||||
|
const response = await service.getDashboard('user-1');
|
||||||
|
|
||||||
|
expect(response.importantLists[0].list.id).toBe('reminder');
|
||||||
|
expect(response.importantLists[0].source).toBe('fallback');
|
||||||
|
expect((await dailySnapshotsRepository.find())[0].errorMessage).toBe(
|
||||||
|
'network down',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the dashboard loadable when weekly suggestions fail', async () => {
|
||||||
|
listsService.listLists.mockResolvedValue([createList('list-1', 'Einkauf')]);
|
||||||
|
mockMistralResponses([
|
||||||
|
{
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
content: '{"lists":[{"listId":"list-1","reason":"Relevant."}]}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
jest.mocked(global.fetch).mockRejectedValueOnce(new Error('weekly failed'));
|
||||||
|
|
||||||
|
const response = await service.getDashboard('user-1');
|
||||||
|
|
||||||
|
expect(response.importantLists).toHaveLength(1);
|
||||||
|
expect(response.weeklySuggestions.suggestions).toEqual([]);
|
||||||
|
expect(response.weeklySuggestions.errorMessage).toBe('weekly failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a concrete list and items from a stored weekly suggestion', async () => {
|
||||||
|
const { weekKey } = currentKeys();
|
||||||
|
const createdList = createList('created-list', 'Meal Prep');
|
||||||
|
const listWithItem = createList('created-list', 'Meal Prep', {
|
||||||
|
items: [
|
||||||
|
{ id: 'created-item', title: 'Rezepte auswaehlen', checked: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
listsService.createList.mockResolvedValue(createdList);
|
||||||
|
listsService.addItem.mockResolvedValue(listWithItem);
|
||||||
|
await weeklySnapshotsRepository.save({
|
||||||
|
id: 'weekly-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
weekKey,
|
||||||
|
timezone: 'UTC',
|
||||||
|
suggestions: [
|
||||||
|
{
|
||||||
|
id: 'suggestion-1',
|
||||||
|
title: 'Meal Prep',
|
||||||
|
description: 'Vorbereitung fuer die Woche',
|
||||||
|
items: [{ title: 'Rezepte auswaehlen', required: true }],
|
||||||
|
reason: 'Ergaenzt Einkaufslisten.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
requestPayload: {},
|
||||||
|
responsePayload: null,
|
||||||
|
errorMessage: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await service.createSuggestionList(
|
||||||
|
'user-1',
|
||||||
|
'suggestion-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(listsService.createList).toHaveBeenCalledWith('user-1', {
|
||||||
|
name: 'Meal Prep',
|
||||||
|
description: 'Vorbereitung fuer die Woche',
|
||||||
|
kind: 'custom',
|
||||||
|
});
|
||||||
|
expect(listsService.addItem).toHaveBeenCalledWith(
|
||||||
|
'user-1',
|
||||||
|
'created-list',
|
||||||
|
{
|
||||||
|
title: 'Rezepte auswaehlen',
|
||||||
|
notes: undefined,
|
||||||
|
quantity: undefined,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(response.list.id).toBe('created-list');
|
||||||
|
await expect(
|
||||||
|
service.createSuggestionList('user-1', 'suggestion-1'),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function createList(
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
overrides: Partial<UserList> & {
|
||||||
|
items?: Array<Partial<UserList['items'][number]>>;
|
||||||
|
} = {},
|
||||||
|
): UserList {
|
||||||
|
const items = (overrides.items ?? []).map((item, index) => ({
|
||||||
|
id: item.id ?? `${id}-item-${index}`,
|
||||||
|
title: item.title ?? `Item ${index}`,
|
||||||
|
required: item.required ?? true,
|
||||||
|
checked: item.checked ?? false,
|
||||||
|
position: item.position ?? index,
|
||||||
|
createdAt: item.createdAt ?? '2026-06-24T00:00:00.000Z',
|
||||||
|
updatedAt: item.updatedAt ?? '2026-06-24T00:00:00.000Z',
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
ownerId: overrides.ownerId ?? 'user-1',
|
||||||
|
accessRole: overrides.accessRole ?? 'owner',
|
||||||
|
name,
|
||||||
|
kind: overrides.kind ?? 'custom',
|
||||||
|
reminderAt: overrides.reminderAt,
|
||||||
|
items,
|
||||||
|
collaborators: [],
|
||||||
|
createdAt: overrides.createdAt ?? '2026-06-24T00:00:00.000Z',
|
||||||
|
updatedAt: overrides.updatedAt ?? '2026-06-24T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockMistralResponses(responses: object[]): void {
|
||||||
|
for (const response of responses) {
|
||||||
|
jest.mocked(global.fetch).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
text: async () => JSON.stringify(response),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCurrentWeeklySnapshot(
|
||||||
|
repository: InMemoryRepository<WeeklyListSuggestionSnapshotEntity>,
|
||||||
|
userId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const { weekKey } = currentKeys();
|
||||||
|
|
||||||
|
await repository.save({
|
||||||
|
id: `${userId}-weekly`,
|
||||||
|
userId,
|
||||||
|
weekKey,
|
||||||
|
timezone: 'UTC',
|
||||||
|
suggestions: [],
|
||||||
|
requestPayload: {},
|
||||||
|
responsePayload: null,
|
||||||
|
errorMessage: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentKeys(): { dateKey: string; weekKey: string } {
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getUTCFullYear();
|
||||||
|
const month = now.getUTCMonth() + 1;
|
||||||
|
const dayOfMonth = now.getUTCDate();
|
||||||
|
const localDate = new Date(Date.UTC(year, month - 1, dayOfMonth));
|
||||||
|
const day = localDate.getUTCDay() || 7;
|
||||||
|
localDate.setUTCDate(localDate.getUTCDate() + 4 - day);
|
||||||
|
const weekYear = localDate.getUTCFullYear();
|
||||||
|
const yearStart = new Date(Date.UTC(weekYear, 0, 1));
|
||||||
|
const week = Math.ceil(
|
||||||
|
((localDate.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
dateKey: `${year}-${pad2(month)}-${pad2(dayOfMonth)}`,
|
||||||
|
weekKey: `${weekYear}-W${pad2(week)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad2(value: number): string {
|
||||||
|
return String(value).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreEnv(key: string, value: string | undefined): void {
|
||||||
|
if (value === undefined) {
|
||||||
|
delete process.env[key];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.env[key] = value;
|
||||||
|
}
|
||||||
874
listify-api/src/dashboard/dashboard.service.ts
Normal file
874
listify-api/src/dashboard/dashboard.service.ts
Normal file
@@ -0,0 +1,874 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { UserList } from '../list-templates/list-template.types';
|
||||||
|
import { ListsService } from '../lists/lists.service';
|
||||||
|
import { DailyDashboardSnapshotEntity } from './daily-dashboard-snapshot.entity';
|
||||||
|
import {
|
||||||
|
CreateDashboardSuggestionResponse,
|
||||||
|
DashboardImportantList,
|
||||||
|
DashboardResponse,
|
||||||
|
DashboardWeeklySuggestion,
|
||||||
|
} from './dashboard.types';
|
||||||
|
import {
|
||||||
|
WeeklyListSuggestionSnapshotEntity,
|
||||||
|
WeeklyListSuggestionSnapshotItem,
|
||||||
|
WeeklyListSuggestionSnapshotSuggestion,
|
||||||
|
} from './weekly-list-suggestion-snapshot.entity';
|
||||||
|
|
||||||
|
interface MistralCompletionResponse {
|
||||||
|
choices?: Array<{
|
||||||
|
message?: {
|
||||||
|
content?: string | null;
|
||||||
|
};
|
||||||
|
messages?: Array<{
|
||||||
|
role?: string;
|
||||||
|
content?: string | null | unknown[];
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
outputs?: Array<{
|
||||||
|
type?: string;
|
||||||
|
role?: string;
|
||||||
|
content?: string | null | unknown[];
|
||||||
|
}>;
|
||||||
|
output_text?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DashboardAiResult<T> {
|
||||||
|
requestPayload: Record<string, unknown>;
|
||||||
|
responsePayload: unknown;
|
||||||
|
value: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
class DashboardAiCallError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly requestPayload: Record<string, unknown>,
|
||||||
|
readonly responsePayload: unknown,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectedListSnapshot {
|
||||||
|
listId: string;
|
||||||
|
reason: string;
|
||||||
|
source: 'ai' | 'fallback';
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DashboardService {
|
||||||
|
private readonly endpoint = 'https://api.mistral.ai/v1/conversations';
|
||||||
|
private readonly defaultModel = 'mistral-large-latest';
|
||||||
|
private readonly defaultTimezone = 'Europe/Budapest';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(DailyDashboardSnapshotEntity)
|
||||||
|
private readonly dailySnapshotsRepository: Repository<DailyDashboardSnapshotEntity>,
|
||||||
|
@InjectRepository(WeeklyListSuggestionSnapshotEntity)
|
||||||
|
private readonly weeklySnapshotsRepository: Repository<WeeklyListSuggestionSnapshotEntity>,
|
||||||
|
private readonly listsService: ListsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getDashboard(userId: string): Promise<DashboardResponse> {
|
||||||
|
const timezone = this.dashboardTimezone();
|
||||||
|
const now = new Date();
|
||||||
|
const dateKey = this.dateKey(now, timezone);
|
||||||
|
const weekKey = this.weekKey(now, timezone);
|
||||||
|
const visibleLists = await this.listsService.listLists(userId);
|
||||||
|
const dailySnapshot = await this.getOrCreateDailySnapshot(
|
||||||
|
userId,
|
||||||
|
dateKey,
|
||||||
|
timezone,
|
||||||
|
visibleLists,
|
||||||
|
);
|
||||||
|
const weeklySnapshot = await this.getOrCreateWeeklySnapshot(
|
||||||
|
userId,
|
||||||
|
weekKey,
|
||||||
|
timezone,
|
||||||
|
visibleLists,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
timezone,
|
||||||
|
dateKey,
|
||||||
|
weekKey,
|
||||||
|
importantLists: this.toImportantLists(dailySnapshot, visibleLists),
|
||||||
|
weeklySuggestions: {
|
||||||
|
suggestions: weeklySnapshot.suggestions.map((suggestion) => ({
|
||||||
|
...suggestion,
|
||||||
|
})),
|
||||||
|
errorMessage: weeklySnapshot.errorMessage ?? undefined,
|
||||||
|
generatedAt: weeklySnapshot.createdAt?.toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSuggestionList(
|
||||||
|
userId: string,
|
||||||
|
suggestionId: string,
|
||||||
|
): Promise<CreateDashboardSuggestionResponse> {
|
||||||
|
const timezone = this.dashboardTimezone();
|
||||||
|
const weekKey = this.weekKey(new Date(), timezone);
|
||||||
|
const snapshot = await this.weeklySnapshotsRepository.findOne({
|
||||||
|
where: { userId, weekKey },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!snapshot) {
|
||||||
|
throw new NotFoundException('Weekly suggestions were not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestion = snapshot.suggestions.find(
|
||||||
|
(candidate) => candidate.id === suggestionId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!suggestion) {
|
||||||
|
throw new NotFoundException('Weekly suggestion was not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suggestion.createdListId) {
|
||||||
|
throw new BadRequestException('Weekly suggestion was already created.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = await this.listsService.createList(userId, {
|
||||||
|
name: suggestion.title,
|
||||||
|
description: suggestion.description,
|
||||||
|
kind: 'custom',
|
||||||
|
});
|
||||||
|
let updatedList = list;
|
||||||
|
|
||||||
|
for (const item of suggestion.items.slice(0, 40)) {
|
||||||
|
updatedList = await this.listsService.addItem(userId, list.id, {
|
||||||
|
title: item.title,
|
||||||
|
notes: item.notes,
|
||||||
|
quantity: item.quantity,
|
||||||
|
required: item.required,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
suggestion.createdListId = updatedList.id;
|
||||||
|
await this.weeklySnapshotsRepository.save(snapshot);
|
||||||
|
|
||||||
|
return {
|
||||||
|
list: updatedList,
|
||||||
|
suggestion: { ...suggestion },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getOrCreateDailySnapshot(
|
||||||
|
userId: string,
|
||||||
|
dateKey: string,
|
||||||
|
timezone: string,
|
||||||
|
visibleLists: UserList[],
|
||||||
|
): Promise<DailyDashboardSnapshotEntity> {
|
||||||
|
const existingSnapshot = await this.dailySnapshotsRepository.findOne({
|
||||||
|
where: { userId, dateKey },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingSnapshot) {
|
||||||
|
return existingSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.selectImportantListsWithAi(visibleLists);
|
||||||
|
return this.dailySnapshotsRepository.save(
|
||||||
|
this.dailySnapshotsRepository.create({
|
||||||
|
id: randomUUID(),
|
||||||
|
userId,
|
||||||
|
dateKey,
|
||||||
|
timezone,
|
||||||
|
selectedLists: result.value,
|
||||||
|
requestPayload: result.requestPayload,
|
||||||
|
responsePayload: result.responsePayload,
|
||||||
|
errorMessage: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return this.dailySnapshotsRepository.save(
|
||||||
|
this.dailySnapshotsRepository.create({
|
||||||
|
id: randomUUID(),
|
||||||
|
userId,
|
||||||
|
dateKey,
|
||||||
|
timezone,
|
||||||
|
selectedLists: this.fallbackImportantLists(visibleLists),
|
||||||
|
requestPayload: this.errorRequestPayload(error, {
|
||||||
|
provider: 'listify',
|
||||||
|
strategy: 'deterministic-dashboard-fallback',
|
||||||
|
}),
|
||||||
|
responsePayload: this.errorResponsePayload(error),
|
||||||
|
errorMessage: this.errorMessage(error),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getOrCreateWeeklySnapshot(
|
||||||
|
userId: string,
|
||||||
|
weekKey: string,
|
||||||
|
timezone: string,
|
||||||
|
visibleLists: UserList[],
|
||||||
|
): Promise<WeeklyListSuggestionSnapshotEntity> {
|
||||||
|
const existingSnapshot = await this.weeklySnapshotsRepository.findOne({
|
||||||
|
where: { userId, weekKey },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingSnapshot) {
|
||||||
|
return existingSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.createWeeklySuggestionsWithAi(visibleLists);
|
||||||
|
return this.weeklySnapshotsRepository.save(
|
||||||
|
this.weeklySnapshotsRepository.create({
|
||||||
|
id: randomUUID(),
|
||||||
|
userId,
|
||||||
|
weekKey,
|
||||||
|
timezone,
|
||||||
|
suggestions: result.value,
|
||||||
|
requestPayload: result.requestPayload,
|
||||||
|
responsePayload: result.responsePayload,
|
||||||
|
errorMessage: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return this.weeklySnapshotsRepository.save(
|
||||||
|
this.weeklySnapshotsRepository.create({
|
||||||
|
id: randomUUID(),
|
||||||
|
userId,
|
||||||
|
weekKey,
|
||||||
|
timezone,
|
||||||
|
suggestions: [],
|
||||||
|
requestPayload: this.errorRequestPayload(error, {
|
||||||
|
provider: 'mistral',
|
||||||
|
endpoint: this.endpoint,
|
||||||
|
errorPhase: 'weekly-suggestions',
|
||||||
|
}),
|
||||||
|
responsePayload: this.errorResponsePayload(error),
|
||||||
|
errorMessage: this.errorMessage(error),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async selectImportantListsWithAi(
|
||||||
|
lists: UserList[],
|
||||||
|
): Promise<DashboardAiResult<SelectedListSnapshot[]>> {
|
||||||
|
if (lists.length === 0) {
|
||||||
|
return {
|
||||||
|
requestPayload: { provider: 'listify', listCount: 0 },
|
||||||
|
responsePayload: null,
|
||||||
|
value: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = this.createDailyPrompt(lists);
|
||||||
|
const aiResponse = await this.callMistral({
|
||||||
|
prompt,
|
||||||
|
instructions:
|
||||||
|
'Waehle fuer das Listify-Dashboard die 5 wichtigsten Listen. Antworte nur mit JSON im Format {"lists":[{"listId":"...","reason":"..."}]}. Keine Markdown-Ausgabe.',
|
||||||
|
});
|
||||||
|
const selectedLists = this.normalizeSelectedLists(
|
||||||
|
aiResponse.responsePayload,
|
||||||
|
lists,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestPayload: aiResponse.requestPayload,
|
||||||
|
responsePayload: aiResponse.responsePayload,
|
||||||
|
value:
|
||||||
|
selectedLists.length > 0
|
||||||
|
? selectedLists
|
||||||
|
: this.fallbackImportantLists(lists),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createWeeklySuggestionsWithAi(
|
||||||
|
lists: UserList[],
|
||||||
|
): Promise<DashboardAiResult<WeeklyListSuggestionSnapshotSuggestion[]>> {
|
||||||
|
const prompt = this.createWeeklyPrompt(lists);
|
||||||
|
const aiResponse = await this.callMistral({
|
||||||
|
prompt,
|
||||||
|
instructions:
|
||||||
|
'Erzeuge 5 neue Listify-Listenvorschlaege. Antworte nur mit JSON im Format {"suggestions":[{"title":"...","description":"...","items":[{"title":"...","notes":"...","quantity":1,"required":true}],"reason":"..."}]}. Keine Markdown-Ausgabe.',
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestPayload: aiResponse.requestPayload,
|
||||||
|
responsePayload: aiResponse.responsePayload,
|
||||||
|
value: this.normalizeWeeklySuggestions(aiResponse.responsePayload),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async callMistral(input: {
|
||||||
|
prompt: string;
|
||||||
|
instructions: string;
|
||||||
|
}): Promise<{
|
||||||
|
requestPayload: Record<string, unknown>;
|
||||||
|
responsePayload: unknown;
|
||||||
|
}> {
|
||||||
|
const apiKey = process.env.MISTRAL_API_KEY;
|
||||||
|
const model = process.env.MISTRAL_MODEL ?? this.defaultModel;
|
||||||
|
const requestPayload = this.requestMetadata(
|
||||||
|
input.prompt,
|
||||||
|
input.instructions,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new DashboardAiCallError(
|
||||||
|
'Mistral API key is not configured.',
|
||||||
|
requestPayload,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(this.endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
inputs: [{ role: 'user', content: input.prompt }],
|
||||||
|
instructions: input.instructions,
|
||||||
|
stream: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const responsePayload = await this.readResponsePayload(response);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new DashboardAiCallError(
|
||||||
|
'Mistral conversation request failed.',
|
||||||
|
requestPayload,
|
||||||
|
responsePayload,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { requestPayload, responsePayload };
|
||||||
|
}
|
||||||
|
|
||||||
|
private readResponsePayload(response: Response): Promise<unknown> {
|
||||||
|
return response.text().then((rawBody) => {
|
||||||
|
if (!rawBody) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(rawBody) as unknown;
|
||||||
|
} catch {
|
||||||
|
return { rawBody };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeSelectedLists(
|
||||||
|
responsePayload: unknown,
|
||||||
|
visibleLists: UserList[],
|
||||||
|
): SelectedListSnapshot[] {
|
||||||
|
const visibleListIds = new Set(visibleLists.map((list) => list.id));
|
||||||
|
const selectedLists: SelectedListSnapshot[] = [];
|
||||||
|
const seenListIds = new Set<string>();
|
||||||
|
const parsed = this.parseObject(
|
||||||
|
this.extractMistralContent(responsePayload),
|
||||||
|
);
|
||||||
|
const candidates = Array.isArray(parsed?.lists) ? parsed.lists : [];
|
||||||
|
|
||||||
|
for (const value of candidates) {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = value as { listId?: unknown; reason?: unknown };
|
||||||
|
const listId =
|
||||||
|
typeof candidate.listId === 'string' ? candidate.listId : '';
|
||||||
|
|
||||||
|
if (
|
||||||
|
!visibleListIds.has(listId) ||
|
||||||
|
seenListIds.has(listId) ||
|
||||||
|
selectedLists.length >= 5
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
seenListIds.add(listId);
|
||||||
|
selectedLists.push({
|
||||||
|
listId,
|
||||||
|
reason:
|
||||||
|
this.compactString(
|
||||||
|
typeof candidate.reason === 'string' ? candidate.reason : undefined,
|
||||||
|
360,
|
||||||
|
) ?? 'Diese Liste ist heute relevant.',
|
||||||
|
source: 'ai',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedLists.length < Math.min(5, visibleLists.length)) {
|
||||||
|
for (const fallback of this.fallbackImportantLists(visibleLists)) {
|
||||||
|
if (seenListIds.has(fallback.listId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedLists.push(fallback);
|
||||||
|
seenListIds.add(fallback.listId);
|
||||||
|
|
||||||
|
if (selectedLists.length === 5) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectedLists;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeWeeklySuggestions(
|
||||||
|
responsePayload: unknown,
|
||||||
|
): WeeklyListSuggestionSnapshotSuggestion[] {
|
||||||
|
const parsed = this.parseObject(
|
||||||
|
this.extractMistralContent(responsePayload),
|
||||||
|
);
|
||||||
|
const candidates = Array.isArray(parsed?.suggestions)
|
||||||
|
? parsed.suggestions
|
||||||
|
: [];
|
||||||
|
const suggestions: WeeklyListSuggestionSnapshotSuggestion[] = [];
|
||||||
|
const seenTitles = new Set<string>();
|
||||||
|
|
||||||
|
for (const value of candidates) {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = value as {
|
||||||
|
title?: unknown;
|
||||||
|
description?: unknown;
|
||||||
|
items?: unknown;
|
||||||
|
reason?: unknown;
|
||||||
|
};
|
||||||
|
const title = this.compactString(
|
||||||
|
typeof candidate.title === 'string' ? candidate.title : undefined,
|
||||||
|
160,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleKey = this.normalizedKey(title);
|
||||||
|
|
||||||
|
if (seenTitles.has(titleKey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = this.normalizeSuggestionItems(candidate.items);
|
||||||
|
|
||||||
|
suggestions.push({
|
||||||
|
id: randomUUID(),
|
||||||
|
title,
|
||||||
|
description: this.compactString(
|
||||||
|
typeof candidate.description === 'string'
|
||||||
|
? candidate.description
|
||||||
|
: undefined,
|
||||||
|
500,
|
||||||
|
),
|
||||||
|
items,
|
||||||
|
reason:
|
||||||
|
this.compactString(
|
||||||
|
typeof candidate.reason === 'string' ? candidate.reason : undefined,
|
||||||
|
360,
|
||||||
|
) ?? 'Passt zu deinen bisherigen Listen.',
|
||||||
|
});
|
||||||
|
seenTitles.add(titleKey);
|
||||||
|
|
||||||
|
if (suggestions.length === 5) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return suggestions;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeSuggestionItems(
|
||||||
|
value: unknown,
|
||||||
|
): WeeklyListSuggestionSnapshotItem[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: WeeklyListSuggestionSnapshotItem[] = [];
|
||||||
|
const seenTitles = new Set<string>();
|
||||||
|
|
||||||
|
for (const itemValue of value) {
|
||||||
|
if (
|
||||||
|
!itemValue ||
|
||||||
|
typeof itemValue !== 'object' ||
|
||||||
|
Array.isArray(itemValue)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = itemValue as {
|
||||||
|
title?: unknown;
|
||||||
|
notes?: unknown;
|
||||||
|
quantity?: unknown;
|
||||||
|
required?: unknown;
|
||||||
|
};
|
||||||
|
const title = this.compactString(
|
||||||
|
typeof candidate.title === 'string' ? candidate.title : undefined,
|
||||||
|
220,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleKey = this.normalizedKey(title);
|
||||||
|
|
||||||
|
if (seenTitles.has(titleKey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
title,
|
||||||
|
notes: this.compactString(
|
||||||
|
typeof candidate.notes === 'string' ? candidate.notes : undefined,
|
||||||
|
500,
|
||||||
|
),
|
||||||
|
quantity:
|
||||||
|
typeof candidate.quantity === 'number' &&
|
||||||
|
Number.isFinite(candidate.quantity) &&
|
||||||
|
candidate.quantity > 0
|
||||||
|
? candidate.quantity
|
||||||
|
: undefined,
|
||||||
|
required:
|
||||||
|
typeof candidate.required === 'boolean' ? candidate.required : true,
|
||||||
|
});
|
||||||
|
seenTitles.add(titleKey);
|
||||||
|
|
||||||
|
if (items.length === 12) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
private fallbackImportantLists(lists: UserList[]): SelectedListSnapshot[] {
|
||||||
|
return [...lists]
|
||||||
|
.sort(
|
||||||
|
(left, right) => this.fallbackScore(right) - this.fallbackScore(left),
|
||||||
|
)
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((list) => ({
|
||||||
|
listId: list.id,
|
||||||
|
reason: this.fallbackReason(list),
|
||||||
|
source: 'fallback',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private fallbackScore(list: UserList): number {
|
||||||
|
const openItems = list.items.filter((item) => !item.checked).length;
|
||||||
|
const updatedAt = new Date(list.updatedAt).getTime();
|
||||||
|
const recencyScore = Number.isFinite(updatedAt)
|
||||||
|
? Math.max(0, 30 - (Date.now() - updatedAt) / 86_400_000)
|
||||||
|
: 0;
|
||||||
|
const reminderScore = list.reminderAt ? 120 : 0;
|
||||||
|
|
||||||
|
return reminderScore + openItems * 8 + recencyScore;
|
||||||
|
}
|
||||||
|
|
||||||
|
private fallbackReason(list: UserList): string {
|
||||||
|
const openItems = list.items.filter((item) => !item.checked).length;
|
||||||
|
|
||||||
|
if (list.reminderAt && openItems > 0) {
|
||||||
|
return 'Diese Liste hat eine Erinnerung und noch offene Eintraege.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (list.reminderAt) {
|
||||||
|
return 'Diese Liste hat eine aktive Erinnerung.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (openItems > 0) {
|
||||||
|
return `Diese Liste hat ${openItems} offene ${openItems === 1 ? 'Eintrag' : 'Eintraege'}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Diese Liste wurde zuletzt aktualisiert.';
|
||||||
|
}
|
||||||
|
|
||||||
|
private toImportantLists(
|
||||||
|
snapshot: DailyDashboardSnapshotEntity,
|
||||||
|
visibleLists: UserList[],
|
||||||
|
): DashboardImportantList[] {
|
||||||
|
const listsById = new Map(visibleLists.map((list) => [list.id, list]));
|
||||||
|
|
||||||
|
return snapshot.selectedLists
|
||||||
|
.map((selectedList) => {
|
||||||
|
const list = listsById.get(selectedList.listId);
|
||||||
|
|
||||||
|
if (!list) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkedItems = list.items.filter((item) => item.checked).length;
|
||||||
|
const totalItems = list.items.length;
|
||||||
|
const openItems = totalItems - checkedItems;
|
||||||
|
|
||||||
|
return {
|
||||||
|
list,
|
||||||
|
reason: selectedList.reason,
|
||||||
|
source: selectedList.source,
|
||||||
|
checkedItems,
|
||||||
|
openItems,
|
||||||
|
totalItems,
|
||||||
|
progress: totalItems === 0 ? 0 : checkedItems / totalItems,
|
||||||
|
hasReminder: Boolean(list.reminderAt),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item): item is DashboardImportantList => item !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createDailyPrompt(lists: UserList[]): string {
|
||||||
|
return [
|
||||||
|
'Waehle die 5 wichtigsten vorhandenen Listen fuer das heutige Dashboard aus.',
|
||||||
|
'Bewerte Erinnerungen, offene Items, Aktualitaet, Kontext und praktischen Nutzen.',
|
||||||
|
'Nutze ausschliesslich die angegebenen listId-Werte.',
|
||||||
|
'',
|
||||||
|
this.formatListsForPrompt(lists),
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
private createWeeklyPrompt(lists: UserList[]): string {
|
||||||
|
return [
|
||||||
|
'Erzeuge 5 neue Listenvorschlaege fuer diesen User.',
|
||||||
|
'Die Vorschlaege sollen vorhandene Listen sinnvoll ergaenzen und nicht einfach duplizieren.',
|
||||||
|
'Jeder Vorschlag braucht Titel, kurze Beschreibung, 3 bis 8 Beispiel-Items und eine kurze Begruendung.',
|
||||||
|
'',
|
||||||
|
this.formatListsForPrompt(lists),
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatListsForPrompt(lists: UserList[]): string {
|
||||||
|
if (lists.length === 0) {
|
||||||
|
return 'Vorhandene sichtbare Listen: keine';
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = ['Vorhandene sichtbare Listen:'];
|
||||||
|
|
||||||
|
for (const list of lists.slice(0, 80)) {
|
||||||
|
const checkedItems = list.items.filter((item) => item.checked).length;
|
||||||
|
const openItems = list.items.length - checkedItems;
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
[
|
||||||
|
`- listId: ${list.id}`,
|
||||||
|
`Name: ${this.compactString(list.name, 160)}`,
|
||||||
|
`Typ: ${list.kind}`,
|
||||||
|
`Beschreibung: ${this.compactString(list.description, 240) ?? 'keine'}`,
|
||||||
|
`Items: ${list.items.length}`,
|
||||||
|
`Offen: ${openItems}`,
|
||||||
|
`Erledigt: ${checkedItems}`,
|
||||||
|
`Erinnerung: ${list.reminderAt ?? 'keine'}`,
|
||||||
|
`Aktualisiert: ${list.updatedAt}`,
|
||||||
|
`Beispiel-Items: ${
|
||||||
|
list.items
|
||||||
|
.slice(0, 8)
|
||||||
|
.map((item) => item.title)
|
||||||
|
.join(', ') || 'keine'
|
||||||
|
}`,
|
||||||
|
].join(' | '),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
private requestMetadata(
|
||||||
|
prompt: string,
|
||||||
|
instructions?: string,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
provider: 'mistral',
|
||||||
|
endpoint: this.endpoint,
|
||||||
|
model: process.env.MISTRAL_MODEL ?? this.defaultModel,
|
||||||
|
prompt,
|
||||||
|
instructions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractMistralContent(responsePayload: unknown): string | null {
|
||||||
|
if (!responsePayload || typeof responsePayload !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = responsePayload as MistralCompletionResponse;
|
||||||
|
const directContent = response.choices?.[0]?.message?.content?.trim();
|
||||||
|
|
||||||
|
if (directContent) {
|
||||||
|
return directContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const choice of response.choices ?? []) {
|
||||||
|
const assistantMessages = (choice.messages ?? [])
|
||||||
|
.filter((message) => message.role === 'assistant')
|
||||||
|
.reverse();
|
||||||
|
|
||||||
|
for (const message of assistantMessages) {
|
||||||
|
const content = this.contentToText(message.content).trim();
|
||||||
|
|
||||||
|
if (content) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputText = response.output_text?.trim();
|
||||||
|
|
||||||
|
if (outputText) {
|
||||||
|
return outputText;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const output of [...(response.outputs ?? [])].reverse()) {
|
||||||
|
const content = this.contentToText(output.content).trim();
|
||||||
|
|
||||||
|
if (
|
||||||
|
content &&
|
||||||
|
(output.role === 'assistant' || output.type?.includes('message'))
|
||||||
|
) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private contentToText(value: unknown): string {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
.map((part) => {
|
||||||
|
if (typeof part === 'string') {
|
||||||
|
return part;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!part || typeof part !== 'object' || Array.isArray(part)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = part as { text?: unknown };
|
||||||
|
return typeof candidate.text === 'string' ? candidate.text : null;
|
||||||
|
})
|
||||||
|
.filter((part): part is string => part !== null)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseObject(content: string | null): Record<string, unknown> | null {
|
||||||
|
if (!content) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(content) as unknown;
|
||||||
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||||
|
? (parsed as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private dateKey(date: Date, timezone: string): string {
|
||||||
|
const parts = this.localDateParts(date, timezone);
|
||||||
|
return `${parts.year}-${this.pad2(parts.month)}-${this.pad2(parts.day)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private weekKey(date: Date, timezone: string): string {
|
||||||
|
const parts = this.localDateParts(date, timezone);
|
||||||
|
const localDate = new Date(
|
||||||
|
Date.UTC(parts.year, parts.month - 1, parts.day),
|
||||||
|
);
|
||||||
|
const day = localDate.getUTCDay() || 7;
|
||||||
|
localDate.setUTCDate(localDate.getUTCDate() + 4 - day);
|
||||||
|
const weekYear = localDate.getUTCFullYear();
|
||||||
|
const yearStart = new Date(Date.UTC(weekYear, 0, 1));
|
||||||
|
const week = Math.ceil(
|
||||||
|
((localDate.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7,
|
||||||
|
);
|
||||||
|
|
||||||
|
return `${weekYear}-W${this.pad2(week)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private localDateParts(
|
||||||
|
date: Date,
|
||||||
|
timezone: string,
|
||||||
|
): { year: number; month: number; day: number } {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: timezone,
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
}).formatToParts(date);
|
||||||
|
const partValue = (type: string) =>
|
||||||
|
Number(parts.find((part) => part.type === type)?.value);
|
||||||
|
|
||||||
|
return {
|
||||||
|
year: partValue('year'),
|
||||||
|
month: partValue('month'),
|
||||||
|
day: partValue('day'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private dashboardTimezone(): string {
|
||||||
|
return process.env.DASHBOARD_TIMEZONE?.trim() || this.defaultTimezone;
|
||||||
|
}
|
||||||
|
|
||||||
|
private pad2(value: number): string {
|
||||||
|
return String(value).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
private compactString(
|
||||||
|
value: string | undefined,
|
||||||
|
maxLength: number,
|
||||||
|
): string | undefined {
|
||||||
|
const compacted = value?.replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
|
if (!compacted) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return compacted.length <= maxLength
|
||||||
|
? compacted
|
||||||
|
: `${compacted.slice(0, maxLength - 3)}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizedKey(value: string): string {
|
||||||
|
return value.trim().replace(/\s+/g, ' ').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
private errorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Dashboard generation failed.';
|
||||||
|
}
|
||||||
|
|
||||||
|
private errorRequestPayload(
|
||||||
|
error: unknown,
|
||||||
|
fallback: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return error instanceof DashboardAiCallError
|
||||||
|
? error.requestPayload
|
||||||
|
: fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private errorResponsePayload(error: unknown): unknown {
|
||||||
|
return error instanceof DashboardAiCallError ? error.responsePayload : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
39
listify-api/src/dashboard/dashboard.types.ts
Normal file
39
listify-api/src/dashboard/dashboard.types.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { UserList } from '../list-templates/list-template.types';
|
||||||
|
import { WeeklyListSuggestionSnapshotItem } from './weekly-list-suggestion-snapshot.entity';
|
||||||
|
|
||||||
|
export interface DashboardImportantList {
|
||||||
|
list: UserList;
|
||||||
|
reason: string;
|
||||||
|
source: 'ai' | 'fallback';
|
||||||
|
checkedItems: number;
|
||||||
|
openItems: number;
|
||||||
|
totalItems: number;
|
||||||
|
progress: number;
|
||||||
|
hasReminder: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardWeeklySuggestion {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
items: WeeklyListSuggestionSnapshotItem[];
|
||||||
|
reason: string;
|
||||||
|
createdListId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardResponse {
|
||||||
|
timezone: string;
|
||||||
|
dateKey: string;
|
||||||
|
weekKey: string;
|
||||||
|
importantLists: DashboardImportantList[];
|
||||||
|
weeklySuggestions: {
|
||||||
|
suggestions: DashboardWeeklySuggestion[];
|
||||||
|
errorMessage?: string;
|
||||||
|
generatedAt?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateDashboardSuggestionResponse {
|
||||||
|
list: UserList;
|
||||||
|
suggestion: DashboardWeeklySuggestion;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
PrimaryColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export interface WeeklyListSuggestionSnapshotItem {
|
||||||
|
title: string;
|
||||||
|
notes?: string;
|
||||||
|
quantity?: number;
|
||||||
|
required: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeeklyListSuggestionSnapshotSuggestion {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
items: WeeklyListSuggestionSnapshotItem[];
|
||||||
|
reason: string;
|
||||||
|
createdListId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('weekly_list_suggestion_snapshots')
|
||||||
|
@Index(['userId', 'weekKey'], { unique: true })
|
||||||
|
export class WeeklyListSuggestionSnapshotEntity {
|
||||||
|
@PrimaryColumn({ type: 'varchar', length: 36 })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ type: 'varchar', length: 36 })
|
||||||
|
userId!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 10 })
|
||||||
|
weekKey!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 80 })
|
||||||
|
timezone!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'json' })
|
||||||
|
suggestions!: WeeklyListSuggestionSnapshotSuggestion[];
|
||||||
|
|
||||||
|
@Column({ type: 'json' })
|
||||||
|
requestPayload!: Record<string, unknown>;
|
||||||
|
|
||||||
|
@Column({ type: 'json', nullable: true })
|
||||||
|
responsePayload?: unknown;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
errorMessage?: string | null;
|
||||||
|
|
||||||
|
@CreateDateColumn({
|
||||||
|
type: 'datetime',
|
||||||
|
precision: 3,
|
||||||
|
default: () => 'CURRENT_TIMESTAMP(3)',
|
||||||
|
})
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({
|
||||||
|
type: 'datetime',
|
||||||
|
precision: 3,
|
||||||
|
default: () => 'CURRENT_TIMESTAMP(3)',
|
||||||
|
onUpdate: 'CURRENT_TIMESTAMP(3)',
|
||||||
|
})
|
||||||
|
updatedAt!: Date;
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import { AssistantChatLogEntity } from '../assistant/assistant-chat-log.entity';
|
|||||||
import { AuditLogEntity } from '../audit/audit-log.entity';
|
import { AuditLogEntity } from '../audit/audit-log.entity';
|
||||||
import { UserEntity } from '../auth/user.entity';
|
import { UserEntity } from '../auth/user.entity';
|
||||||
import { RefreshTokenEntity } from '../auth/refresh-token.entity';
|
import { RefreshTokenEntity } from '../auth/refresh-token.entity';
|
||||||
|
import { DailyDashboardSnapshotEntity } from '../dashboard/daily-dashboard-snapshot.entity';
|
||||||
|
import { WeeklyListSuggestionSnapshotEntity } from '../dashboard/weekly-list-suggestion-snapshot.entity';
|
||||||
import { ListTemplateEntity } from '../list-templates/list-template.entity';
|
import { ListTemplateEntity } from '../list-templates/list-template.entity';
|
||||||
import { ListTemplateItemEntity } from '../list-templates/list-template-item.entity';
|
import { ListTemplateItemEntity } from '../list-templates/list-template-item.entity';
|
||||||
import { ListTemplateShareEntity } from '../list-templates/list-template-share.entity';
|
import { ListTemplateShareEntity } from '../list-templates/list-template-share.entity';
|
||||||
@@ -32,6 +34,7 @@ export default new DataSource({
|
|||||||
entities: [
|
entities: [
|
||||||
AssistantChatLogEntity,
|
AssistantChatLogEntity,
|
||||||
AuditLogEntity,
|
AuditLogEntity,
|
||||||
|
DailyDashboardSnapshotEntity,
|
||||||
UserEntity,
|
UserEntity,
|
||||||
RefreshTokenEntity,
|
RefreshTokenEntity,
|
||||||
ListTemplateEntity,
|
ListTemplateEntity,
|
||||||
@@ -40,6 +43,7 @@ export default new DataSource({
|
|||||||
TemplateSeedEntity,
|
TemplateSeedEntity,
|
||||||
UserListEntity,
|
UserListEntity,
|
||||||
UserListItemEntity,
|
UserListItemEntity,
|
||||||
|
WeeklyListSuggestionSnapshotEntity,
|
||||||
],
|
],
|
||||||
migrations: ['src/database/migrations/*.ts'],
|
migrations: ['src/database/migrations/*.ts'],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateDashboardSnapshots1781800000000 implements MigrationInterface {
|
||||||
|
name = 'CreateDashboardSnapshots1781800000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
if (!(await queryRunner.hasTable('daily_dashboard_snapshots'))) {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE \`daily_dashboard_snapshots\` (
|
||||||
|
\`id\` varchar(36) NOT NULL,
|
||||||
|
\`userId\` varchar(36) NOT NULL,
|
||||||
|
\`dateKey\` varchar(10) NOT NULL,
|
||||||
|
\`timezone\` varchar(80) NOT NULL,
|
||||||
|
\`selectedLists\` json NOT NULL,
|
||||||
|
\`requestPayload\` json NOT NULL,
|
||||||
|
\`responsePayload\` json NULL,
|
||||||
|
\`errorMessage\` text NULL,
|
||||||
|
\`createdAt\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
\`updatedAt\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
INDEX \`IDX_daily_dashboard_snapshots_user_id\` (\`userId\`),
|
||||||
|
UNIQUE INDEX \`IDX_daily_dashboard_snapshots_user_date\` (\`userId\`, \`dateKey\`),
|
||||||
|
PRIMARY KEY (\`id\`)
|
||||||
|
) ENGINE=InnoDB
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await queryRunner.hasTable('weekly_list_suggestion_snapshots'))) {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE \`weekly_list_suggestion_snapshots\` (
|
||||||
|
\`id\` varchar(36) NOT NULL,
|
||||||
|
\`userId\` varchar(36) NOT NULL,
|
||||||
|
\`weekKey\` varchar(10) NOT NULL,
|
||||||
|
\`timezone\` varchar(80) NOT NULL,
|
||||||
|
\`suggestions\` json NOT NULL,
|
||||||
|
\`requestPayload\` json NOT NULL,
|
||||||
|
\`responsePayload\` json NULL,
|
||||||
|
\`errorMessage\` text NULL,
|
||||||
|
\`createdAt\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
\`updatedAt\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
INDEX \`IDX_weekly_list_suggestion_snapshots_user_id\` (\`userId\`),
|
||||||
|
UNIQUE INDEX \`IDX_weekly_list_suggestion_snapshots_user_week\` (\`userId\`, \`weekKey\`),
|
||||||
|
PRIMARY KEY (\`id\`)
|
||||||
|
) ENGINE=InnoDB
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
if (await queryRunner.hasTable('weekly_list_suggestion_snapshots')) {
|
||||||
|
await queryRunner.query('DROP TABLE `weekly_list_suggestion_snapshots`');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await queryRunner.hasTable('daily_dashboard_snapshots')) {
|
||||||
|
await queryRunner.query('DROP TABLE `daily_dashboard_snapshots`');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
<a
|
<a
|
||||||
class="brand"
|
class="brand"
|
||||||
[routerLink]="auth.isAuthenticated() ? '/lists' : '/login'"
|
[routerLink]="auth.isAuthenticated() ? '/dashboard' : '/login'"
|
||||||
aria-label="Listify Startseite"
|
aria-label="Listify Startseite"
|
||||||
>
|
>
|
||||||
<mat-icon aria-hidden="true">checklist</mat-icon>
|
<mat-icon aria-hidden="true">checklist</mat-icon>
|
||||||
@@ -64,6 +64,16 @@
|
|||||||
>
|
>
|
||||||
<nav aria-label="Hauptnavigation">
|
<nav aria-label="Hauptnavigation">
|
||||||
<mat-nav-list>
|
<mat-nav-list>
|
||||||
|
<a
|
||||||
|
mat-list-item
|
||||||
|
routerLink="/dashboard"
|
||||||
|
routerLinkActive="active-nav-link"
|
||||||
|
ariaCurrentWhenActive="page"
|
||||||
|
(click)="closeSidebarOnCompact()"
|
||||||
|
>
|
||||||
|
<mat-icon matListItemIcon aria-hidden="true">space_dashboard</mat-icon>
|
||||||
|
<span matListItemTitle>Dashboard</span>
|
||||||
|
</a>
|
||||||
<a
|
<a
|
||||||
mat-list-item
|
mat-list-item
|
||||||
routerLink="/templates"
|
routerLink="/templates"
|
||||||
@@ -120,6 +130,15 @@
|
|||||||
<app-assistant-chat />
|
<app-assistant-chat />
|
||||||
|
|
||||||
<nav class="bottom-nav" aria-label="Mobile Hauptnavigation">
|
<nav class="bottom-nav" aria-label="Mobile Hauptnavigation">
|
||||||
|
<a
|
||||||
|
class="bottom-nav-link"
|
||||||
|
routerLink="/dashboard"
|
||||||
|
routerLinkActive="active-bottom-link"
|
||||||
|
ariaCurrentWhenActive="page"
|
||||||
|
>
|
||||||
|
<mat-icon aria-hidden="true">space_dashboard</mat-icon>
|
||||||
|
<span>Dashboard</span>
|
||||||
|
</a>
|
||||||
<a
|
<a
|
||||||
class="bottom-nav-link"
|
class="bottom-nav-link"
|
||||||
routerLink="/templates"
|
routerLink="/templates"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { TemplatesComponent } from './templates/templates.component';
|
|||||||
import { TemplateDetailComponent } from './templates/template-detail/template-detail.component';
|
import { TemplateDetailComponent } from './templates/template-detail/template-detail.component';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{ path: '', pathMatch: 'full', redirectTo: 'lists' },
|
{ path: '', pathMatch: 'full', redirectTo: 'dashboard' },
|
||||||
{ path: 'login', component: LoginComponent, canActivate: [unauthGuard] },
|
{ path: 'login', component: LoginComponent, canActivate: [unauthGuard] },
|
||||||
{ path: 'register', component: RegisterComponent, canActivate: [unauthGuard] },
|
{ path: 'register', component: RegisterComponent, canActivate: [unauthGuard] },
|
||||||
{ path: 'verify-email', component: VerifyEmailComponent, canActivate: [unauthGuard] },
|
{ path: 'verify-email', component: VerifyEmailComponent, canActivate: [unauthGuard] },
|
||||||
@@ -19,6 +19,12 @@ export const routes: Routes = [
|
|||||||
path: 'auth',
|
path: 'auth',
|
||||||
children: [{ path: 'verify-email', component: VerifyEmailComponent }],
|
children: [{ path: 'verify-email', component: VerifyEmailComponent }],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'dashboard',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./dashboard/dashboard.component').then((module) => module.DashboardComponent),
|
||||||
|
canActivate: [authGuard],
|
||||||
|
},
|
||||||
{ path: 'templates', component: TemplatesComponent, canActivate: [authGuard] },
|
{ path: 'templates', component: TemplatesComponent, canActivate: [authGuard] },
|
||||||
{
|
{
|
||||||
path: 'templates/new',
|
path: 'templates/new',
|
||||||
|
|||||||
@@ -2,8 +2,16 @@
|
|||||||
display: block;
|
display: block;
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
background:
|
background:
|
||||||
linear-gradient(140deg, color-mix(in srgb, var(--mat-sys-primary) 14%, transparent), transparent 38%),
|
linear-gradient(
|
||||||
linear-gradient(320deg, color-mix(in srgb, var(--mat-sys-tertiary) 12%, transparent), transparent 36%),
|
140deg,
|
||||||
|
color-mix(in srgb, var(--mat-sys-primary) 14%, transparent),
|
||||||
|
transparent 38%
|
||||||
|
),
|
||||||
|
linear-gradient(
|
||||||
|
320deg,
|
||||||
|
color-mix(in srgb, var(--mat-sys-tertiary) 12%, transparent),
|
||||||
|
transparent 36%
|
||||||
|
),
|
||||||
var(--mat-sys-surface-container);
|
var(--mat-sys-surface-container);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,7 +148,7 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
padding: 0.35rem 0.5rem calc(0.35rem + env(safe-area-inset-bottom));
|
padding: 0.35rem 0.5rem calc(0.35rem + env(safe-area-inset-bottom));
|
||||||
border-top: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent);
|
border-top: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent);
|
||||||
|
|||||||
183
listify-client/src/app/dashboard/dashboard.component.html
Normal file
183
listify-client/src/app/dashboard/dashboard.component.html
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
<section class="workspace-page dashboard-page">
|
||||||
|
<header class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1>Dashboard</h1>
|
||||||
|
<p>Deine wichtigsten Listen und neue Ideen fuer diese Woche.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a mat-flat-button routerLink="/lists/new">
|
||||||
|
<mat-icon aria-hidden="true">add</mat-icon>
|
||||||
|
Neue Liste
|
||||||
|
</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<mat-card class="state-card" appearance="outlined">
|
||||||
|
<mat-card-content>
|
||||||
|
<mat-progress-spinner mode="indeterminate" diameter="40" />
|
||||||
|
<h2>Dashboard wird geladen</h2>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
} @else if (errorMessage()) {
|
||||||
|
<mat-card class="state-card error-state" appearance="outlined">
|
||||||
|
<mat-card-content>
|
||||||
|
<mat-icon aria-hidden="true">error</mat-icon>
|
||||||
|
<h2>Dashboard konnte nicht geladen werden</h2>
|
||||||
|
<p>{{ errorMessage() }}</p>
|
||||||
|
<button mat-stroked-button type="button" (click)="loadDashboard()">
|
||||||
|
<mat-icon aria-hidden="true">refresh</mat-icon>
|
||||||
|
Erneut laden
|
||||||
|
</button>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
} @else if (dashboard()) {
|
||||||
|
<section class="dashboard-section" aria-labelledby="important-lists-title">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<h2 id="important-lists-title">Wichtige Listen heute</h2>
|
||||||
|
<p>{{ dashboard()!.dateKey }} - {{ dashboard()!.timezone }}</p>
|
||||||
|
</div>
|
||||||
|
<a mat-stroked-button routerLink="/lists">
|
||||||
|
<mat-icon aria-hidden="true">format_list_bulleted</mat-icon>
|
||||||
|
Alle Listen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (hasImportantLists()) {
|
||||||
|
<div class="important-list-grid">
|
||||||
|
@for (item of dashboard()!.importantLists; track item.list.id) {
|
||||||
|
<mat-card class="important-list-card" appearance="outlined">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>{{ item.list.name }}</mat-card-title>
|
||||||
|
<mat-card-subtitle>
|
||||||
|
{{ item.checkedItems }} / {{ item.totalItems }} erledigt
|
||||||
|
@if (item.list.accessRole === 'collaborator') {
|
||||||
|
- geteilt
|
||||||
|
}
|
||||||
|
</mat-card-subtitle>
|
||||||
|
</mat-card-header>
|
||||||
|
|
||||||
|
<mat-card-content>
|
||||||
|
<mat-progress-bar mode="determinate" [value]="progressPercent(item.progress)" />
|
||||||
|
|
||||||
|
<div class="dashboard-meta">
|
||||||
|
<span>
|
||||||
|
<mat-icon aria-hidden="true">radio_button_unchecked</mat-icon>
|
||||||
|
{{ item.openItems }} offen
|
||||||
|
</span>
|
||||||
|
@if (item.list.reminderAt) {
|
||||||
|
<span>
|
||||||
|
<mat-icon aria-hidden="true">notifications</mat-icon>
|
||||||
|
{{ item.list.reminderAt | date: 'dd.MM., HH:mm' }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
<span>
|
||||||
|
<mat-icon aria-hidden="true">
|
||||||
|
{{ item.source === 'ai' ? 'auto_awesome' : 'rule' }}
|
||||||
|
</mat-icon>
|
||||||
|
{{ item.source === 'ai' ? 'KI' : 'Fallback' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="ai-reason">{{ item.reason }}</p>
|
||||||
|
</mat-card-content>
|
||||||
|
|
||||||
|
<mat-card-actions align="end">
|
||||||
|
<a mat-button [routerLink]="['/lists', item.list.id]">
|
||||||
|
<mat-icon aria-hidden="true">open_in_new</mat-icon>
|
||||||
|
Oeffnen
|
||||||
|
</a>
|
||||||
|
</mat-card-actions>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<mat-card class="state-card" appearance="outlined">
|
||||||
|
<mat-card-content>
|
||||||
|
<mat-icon aria-hidden="true">format_list_bulleted</mat-icon>
|
||||||
|
<h2>Noch keine wichtigen Listen</h2>
|
||||||
|
<p>Erstelle eine Liste oder teile eine vorhandene Liste mit deinem Account.</p>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="dashboard-section" aria-labelledby="weekly-suggestions-title">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<h2 id="weekly-suggestions-title">Listenvorschlaege dieser Woche</h2>
|
||||||
|
<p>{{ dashboard()!.weekKey }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (dashboard()!.weeklySuggestions.errorMessage && suggestions().length === 0) {
|
||||||
|
<mat-card class="state-card error-state" appearance="outlined">
|
||||||
|
<mat-card-content>
|
||||||
|
<mat-icon aria-hidden="true">tips_and_updates</mat-icon>
|
||||||
|
<h2>Keine Vorschlaege verfuegbar</h2>
|
||||||
|
<p>{{ dashboard()!.weeklySuggestions.errorMessage }}</p>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
} @else if (suggestions().length > 0) {
|
||||||
|
<div class="suggestion-grid">
|
||||||
|
@for (suggestion of suggestions(); track suggestion.id) {
|
||||||
|
<mat-card class="suggestion-card" appearance="outlined">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>{{ suggestion.title }}</mat-card-title>
|
||||||
|
@if (suggestion.description) {
|
||||||
|
<mat-card-subtitle>{{ suggestion.description }}</mat-card-subtitle>
|
||||||
|
}
|
||||||
|
</mat-card-header>
|
||||||
|
|
||||||
|
<mat-card-content>
|
||||||
|
<p class="ai-reason">{{ suggestion.reason }}</p>
|
||||||
|
|
||||||
|
@if (suggestion.items.length > 0) {
|
||||||
|
<ul class="suggestion-items">
|
||||||
|
@for (item of suggestion.items.slice(0, 5); track item.title) {
|
||||||
|
<li>
|
||||||
|
<mat-icon aria-hidden="true">add_task</mat-icon>
|
||||||
|
<span>{{ item.title }}</span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</mat-card-content>
|
||||||
|
|
||||||
|
<mat-card-actions align="end">
|
||||||
|
@if (suggestion.createdListId) {
|
||||||
|
<a mat-button [routerLink]="['/lists', suggestion.createdListId]">
|
||||||
|
<mat-icon aria-hidden="true">open_in_new</mat-icon>
|
||||||
|
Zur Liste
|
||||||
|
</a>
|
||||||
|
} @else {
|
||||||
|
<button
|
||||||
|
mat-flat-button
|
||||||
|
type="button"
|
||||||
|
[disabled]="creatingSuggestionId() === suggestion.id"
|
||||||
|
(click)="createSuggestion(suggestion)"
|
||||||
|
>
|
||||||
|
@if (creatingSuggestionId() === suggestion.id) {
|
||||||
|
<mat-progress-spinner mode="indeterminate" diameter="18" />
|
||||||
|
} @else {
|
||||||
|
<mat-icon aria-hidden="true">playlist_add</mat-icon>
|
||||||
|
}
|
||||||
|
Liste erstellen
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</mat-card-actions>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<mat-card class="state-card" appearance="outlined">
|
||||||
|
<mat-card-content>
|
||||||
|
<mat-icon aria-hidden="true">tips_and_updates</mat-icon>
|
||||||
|
<h2>Keine Vorschlaege verfuegbar</h2>
|
||||||
|
<p>Fuer diese Woche wurden keine neuen Listenvorschlaege gespeichert.</p>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
142
listify-client/src/app/dashboard/dashboard.component.scss
Normal file
142
listify-client/src/app/dashboard/dashboard.component.scss
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
.dashboard-page {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.85rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading p {
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
color: var(--mat-sys-on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.important-list-grid,
|
||||||
|
.suggestion-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.important-list-card,
|
||||||
|
.suggestion-card {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--mat-sys-surface);
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.important-list-card mat-card-title,
|
||||||
|
.important-list-card mat-card-subtitle,
|
||||||
|
.suggestion-card mat-card-title,
|
||||||
|
.suggestion-card mat-card-subtitle {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.45rem 0.75rem;
|
||||||
|
margin-top: 0.85rem;
|
||||||
|
color: var(--mat-sys-on-surface-variant);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-meta span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-meta mat-icon,
|
||||||
|
.suggestion-items mat-icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
color: var(--mat-sys-primary);
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-reason {
|
||||||
|
margin: 0.85rem 0 0;
|
||||||
|
color: var(--mat-sys-on-surface-variant);
|
||||||
|
line-height: 1.45;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-items {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-items li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-items span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
button mat-progress-spinner,
|
||||||
|
a mat-progress-spinner {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 701px) {
|
||||||
|
.dashboard-page {
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading {
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.important-list-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1040px) {
|
||||||
|
.important-list-grid {
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-grid {
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
105
listify-client/src/app/dashboard/dashboard.component.ts
Normal file
105
listify-client/src/app/dashboard/dashboard.component.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { DatePipe } from '@angular/common';
|
||||||
|
import { Component, OnInit, computed, inject, signal } from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { finalize } from 'rxjs';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
|
import { getAuthErrorMessage } from '../auth/error-message';
|
||||||
|
import { DashboardResponse, DashboardWeeklySuggestion } from './dashboard.models';
|
||||||
|
import { DashboardService } from './dashboard.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-dashboard',
|
||||||
|
imports: [
|
||||||
|
DatePipe,
|
||||||
|
RouterLink,
|
||||||
|
MatButtonModule,
|
||||||
|
MatCardModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatProgressBarModule,
|
||||||
|
MatProgressSpinnerModule,
|
||||||
|
MatSnackBarModule,
|
||||||
|
],
|
||||||
|
templateUrl: './dashboard.component.html',
|
||||||
|
styleUrls: ['../workspace-page.scss', './dashboard.component.scss'],
|
||||||
|
})
|
||||||
|
export class DashboardComponent implements OnInit {
|
||||||
|
private readonly dashboardService = inject(DashboardService);
|
||||||
|
private readonly snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
|
protected readonly dashboard = signal<DashboardResponse | null>(null);
|
||||||
|
protected readonly loading = signal(true);
|
||||||
|
protected readonly errorMessage = signal<string | null>(null);
|
||||||
|
protected readonly creatingSuggestionId = signal<string | null>(null);
|
||||||
|
protected readonly hasImportantLists = computed(
|
||||||
|
() => (this.dashboard()?.importantLists.length ?? 0) > 0,
|
||||||
|
);
|
||||||
|
protected readonly suggestions = computed(
|
||||||
|
() => this.dashboard()?.weeklySuggestions.suggestions ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loadDashboard();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected loadDashboard(): void {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.errorMessage.set(null);
|
||||||
|
|
||||||
|
this.dashboardService.getDashboard().subscribe({
|
||||||
|
next: (dashboard) => {
|
||||||
|
this.dashboard.set(dashboard);
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: (error: unknown) => {
|
||||||
|
this.errorMessage.set(getAuthErrorMessage(error));
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected createSuggestion(suggestion: DashboardWeeklySuggestion): void {
|
||||||
|
if (suggestion.createdListId || this.creatingSuggestionId()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.creatingSuggestionId.set(suggestion.id);
|
||||||
|
this.dashboardService
|
||||||
|
.createSuggestion(suggestion.id)
|
||||||
|
.pipe(finalize(() => this.creatingSuggestionId.set(null)))
|
||||||
|
.subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.dashboard.update((dashboard) =>
|
||||||
|
dashboard
|
||||||
|
? {
|
||||||
|
...dashboard,
|
||||||
|
weeklySuggestions: {
|
||||||
|
...dashboard.weeklySuggestions,
|
||||||
|
suggestions: dashboard.weeklySuggestions.suggestions.map(
|
||||||
|
(existingSuggestion) =>
|
||||||
|
existingSuggestion.id === response.suggestion.id
|
||||||
|
? response.suggestion
|
||||||
|
: existingSuggestion,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: dashboard,
|
||||||
|
);
|
||||||
|
this.snackBar.open('Liste erstellt.', 'OK', { duration: 2500 });
|
||||||
|
},
|
||||||
|
error: (error: unknown) => {
|
||||||
|
this.snackBar.open(getAuthErrorMessage(error), 'OK', {
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected progressPercent(value: number): number {
|
||||||
|
return Math.round(value * 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
45
listify-client/src/app/dashboard/dashboard.models.ts
Normal file
45
listify-client/src/app/dashboard/dashboard.models.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { UserList } from '../lists/lists.models';
|
||||||
|
|
||||||
|
export interface DashboardImportantList {
|
||||||
|
list: UserList;
|
||||||
|
reason: string;
|
||||||
|
source: 'ai' | 'fallback';
|
||||||
|
checkedItems: number;
|
||||||
|
openItems: number;
|
||||||
|
totalItems: number;
|
||||||
|
progress: number;
|
||||||
|
hasReminder: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardWeeklySuggestionItem {
|
||||||
|
title: string;
|
||||||
|
notes?: string;
|
||||||
|
quantity?: number;
|
||||||
|
required: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardWeeklySuggestion {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
items: DashboardWeeklySuggestionItem[];
|
||||||
|
reason: string;
|
||||||
|
createdListId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardResponse {
|
||||||
|
timezone: string;
|
||||||
|
dateKey: string;
|
||||||
|
weekKey: string;
|
||||||
|
importantLists: DashboardImportantList[];
|
||||||
|
weeklySuggestions: {
|
||||||
|
suggestions: DashboardWeeklySuggestion[];
|
||||||
|
errorMessage?: string;
|
||||||
|
generatedAt?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateDashboardSuggestionResponse {
|
||||||
|
list: UserList;
|
||||||
|
suggestion: DashboardWeeklySuggestion;
|
||||||
|
}
|
||||||
21
listify-client/src/app/dashboard/dashboard.service.ts
Normal file
21
listify-client/src/app/dashboard/dashboard.service.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { CreateDashboardSuggestionResponse, DashboardResponse } from './dashboard.models';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class DashboardService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
private readonly apiUrl = '/api/dashboard';
|
||||||
|
|
||||||
|
getDashboard(): Observable<DashboardResponse> {
|
||||||
|
return this.http.get<DashboardResponse>(this.apiUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
createSuggestion(suggestionId: string): Observable<CreateDashboardSuggestionResponse> {
|
||||||
|
return this.http.post<CreateDashboardSuggestionResponse>(
|
||||||
|
`${this.apiUrl}/weekly-suggestions/${suggestionId}/create`,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user