feat(contacts): add import/export with Google Contacts integration

- Add vCard/CSV file import with duplicate detection and merge options
- Add Google Contacts OAuth2 integration for importing from Google
- Add vCard/CSV export with format selection and filtering options
- Add connected_accounts table for OAuth token storage
- Add FileUploader, ImportPreview, GoogleImport components
- Add ExportModal with format selection (vCard/CSV)
- Add i18n translations for import/export (DE/EN)
This commit is contained in:
Till-JS 2025-12-03 15:54:31 +01:00
parent 32eef4005d
commit 79b629b820
98 changed files with 5302 additions and 6 deletions

View file

@ -39,6 +39,7 @@ For comprehensive guidelines on code patterns and conventions, see the `.claude/
| **zitare** | Daily inspiration quotes | NestJS backend, Expo mobile, SvelteKit web, Astro landing |
| **presi** | Presentation tool | NestJS backend, Expo mobile, SvelteKit web |
| **contacts** | Contact management | NestJS backend, SvelteKit web |
| **mail** | Email client with AI | NestJS backend, Expo mobile, SvelteKit web, Astro landing |
### Archived Projects (`apps-archived/`)
@ -69,6 +70,7 @@ pnpm run chat:dev
pnpm run zitare:dev
pnpm run presi:dev
pnpm run contacts:dev
pnpm run mail:dev
# Start specific app within project
pnpm run dev:chat:mobile # Just mobile app

View file

@ -7,7 +7,7 @@
| App | Port | URL |
|-----|------|-----|
| Backend | 3017 | http://localhost:3017 |
| Web App | 5186 | http://localhost:5186 |
| Web App | 5187 | http://localhost:5187 |
| Landing Page | 4323 | http://localhost:4323 |
## Project Structure
@ -72,7 +72,7 @@ pnpm clock:dev # Run all clock apps
# Einzelne Apps starten
pnpm dev:clock:backend # Start backend server (port 3017)
pnpm dev:clock:web # Start web app (port 5186)
pnpm dev:clock:web # Start web app (port 5187)
pnpm dev:clock:landing # Start landing page (port 4323)
pnpm dev:clock:app # Start web + backend together
@ -340,7 +340,7 @@ pnpm dev:clock:landing # Terminal 3 (optional)
### 3. URLs öffnen
- Web App: http://localhost:5186
- Web App: http://localhost:5187
- Landing: http://localhost:4323
- API Health: http://localhost:3017/api/v1/health
@ -379,7 +379,7 @@ curl -X POST http://localhost:3017/api/v1/timers/$TIMER_ID/start \
1. **Authentication**: Nutzt Mana Core Auth (JWT im Authorization Header)
2. **Database**: PostgreSQL mit Drizzle ORM (Port 5432)
3. **Port**: Backend läuft auf Port 3017, Web auf 5186, Landing auf 4323
3. **Port**: Backend läuft auf Port 3017, Web auf 5187, Landing auf 4323
4. **i18n**: 5 Sprachen unterstützt (DE, EN, FR, ES, IT)
5. **Theme**: Amber/Orange (#f59e0b) als Primärfarbe
6. **Local Features**: Stoppuhr und Pomodoro laufen lokal ohne Backend-Sync

View file

@ -5,7 +5,7 @@ import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
server: {
port: 5186,
port: 5187,
strictPort: true,
},
ssr: {

249
apps/todo/CLAUDE.md Normal file
View file

@ -0,0 +1,249 @@
# Todo Project Guide
## Overview
**Todo** is a full-featured task management application for the ManaCore ecosystem. It supports projects, tasks with subtasks, labels, recurring tasks, reminders, and calendar integration.
| App | Port | URL |
|-----|------|-----|
| Backend | 3018 | http://localhost:3018 |
| Web App | 5188 | http://localhost:5188 |
| Landing Page | 4323 | http://localhost:4323 |
## Project Structure
```
apps/todo/
├── apps/
│ ├── backend/ # NestJS API server (@todo/backend)
│ ├── web/ # SvelteKit web application (@todo/web)
│ └── landing/ # Astro marketing landing page (@todo/landing)
├── packages/
│ └── shared/ # Shared types, utils, constants (@todo/shared)
├── package.json
└── CLAUDE.md
```
## Commands
### Root Level (from monorepo root)
```bash
# All apps
pnpm todo:dev # Run all todo apps
# Individual apps
pnpm dev:todo:backend # Start backend server (port 3018)
pnpm dev:todo:web # Start web app (port 5188)
pnpm dev:todo:landing # Start landing page (port 4323)
pnpm dev:todo:app # Start web + backend together
# Database
pnpm todo:db:push # Push schema to database
pnpm todo:db:studio # Open Drizzle Studio
pnpm todo:db:seed # Seed initial data
```
### Backend (apps/todo/apps/backend)
```bash
pnpm dev # Start with hot reload
pnpm build # Build for production
pnpm start:prod # Start production server
pnpm db:push # Push schema to database
pnpm db:studio # Open Drizzle Studio
pnpm db:seed # Seed initial data
```
### Web App (apps/todo/apps/web)
```bash
pnpm dev # Start dev server
pnpm build # Build for production
pnpm preview # Preview production build
```
### Landing Page (apps/todo/apps/landing)
```bash
pnpm dev # Start dev server (port 4323)
pnpm build # Build for production
pnpm preview # Preview build
```
## Technology Stack
| Layer | Technology |
|-------|------------|
| **Backend** | NestJS 10, Drizzle ORM, PostgreSQL |
| **Web** | SvelteKit 2.x, Svelte 5 (runes mode), Tailwind CSS 4 |
| **Landing** | Astro 5.x, Tailwind CSS |
| **Auth** | Mana Core Auth (JWT) |
| **i18n** | svelte-i18n (DE, EN) |
| **Dates** | date-fns |
## Core Features
1. **Projects** - Organize tasks into color-coded projects
2. **Tasks** - Full CRUD with priority, due dates, and status
3. **Subtasks** - Checklist items within tasks
4. **Labels** - Tag tasks with colored labels
5. **Recurring Tasks** - Daily, weekly, monthly (RFC 5545 RRULE)
6. **Reminders** - Push and email notifications
7. **Calendar Integration** - Sync tasks with Calendar app
8. **Quick Add** - Natural language task creation
## Views
| View | Route | Description |
|------|-------|-------------|
| **Inbox** | `/` (default) | Tasks without a project |
| **Today** | `/today` | Due today + overdue |
| **Upcoming** | `/upcoming` | Next 7 days, grouped by date |
| **Project** | `/project/[id]` | Tasks in specific project |
| **Label** | `/label/[id]` | Tasks with specific label |
| **Completed** | `/completed` | Completed tasks archive |
| **Search** | `/search` | Full-text search |
## API Endpoints
### Projects
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/projects` | GET | List user's projects |
| `/api/v1/projects` | POST | Create project |
| `/api/v1/projects/:id` | GET | Get project details |
| `/api/v1/projects/:id` | PUT | Update project |
| `/api/v1/projects/:id` | DELETE | Delete project |
| `/api/v1/projects/:id/archive` | POST | Archive project |
| `/api/v1/projects/reorder` | PUT | Reorder projects |
### Tasks
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/tasks` | GET | Query tasks (filters) |
| `/api/v1/tasks` | POST | Create task |
| `/api/v1/tasks/:id` | GET | Get task details |
| `/api/v1/tasks/:id` | PUT | Update task |
| `/api/v1/tasks/:id` | DELETE | Delete task |
| `/api/v1/tasks/:id/complete` | POST | Mark complete |
| `/api/v1/tasks/:id/uncomplete` | POST | Mark incomplete |
| `/api/v1/tasks/:id/move` | POST | Move to project |
| `/api/v1/tasks/:id/labels` | PUT | Update labels |
| `/api/v1/tasks/inbox` | GET | Inbox tasks |
| `/api/v1/tasks/today` | GET | Today's tasks |
| `/api/v1/tasks/upcoming` | GET | Upcoming tasks |
| `/api/v1/tasks/reorder` | PUT | Reorder tasks |
### Labels
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/labels` | GET | List labels |
| `/api/v1/labels` | POST | Create label |
| `/api/v1/labels/:id` | PUT | Update label |
| `/api/v1/labels/:id` | DELETE | Delete label |
### Reminders
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/tasks/:taskId/reminders` | GET | List reminders |
| `/api/v1/tasks/:taskId/reminders` | POST | Add reminder |
| `/api/v1/reminders/:id` | DELETE | Delete reminder |
## Database Schema
### projects
- `id` (UUID) - Primary key
- `user_id` (UUID) - Owner
- `name` (VARCHAR) - Project name
- `color` (VARCHAR) - Hex color
- `icon` (VARCHAR) - Icon name
- `order` (INTEGER) - Sort order
- `is_archived` (BOOLEAN) - Archive flag
- `is_default` (BOOLEAN) - Inbox project
### tasks
- `id` (UUID) - Primary key
- `project_id` (UUID) - FK to projects (nullable = Inbox)
- `user_id` (UUID) - Owner
- `title` (VARCHAR) - Task title
- `description` (TEXT) - Description
- `due_date` (TIMESTAMP) - Due date
- `priority` (VARCHAR) - low/medium/high/urgent
- `is_completed` (BOOLEAN) - Completion flag
- `order` (INTEGER) - Sort order
- `recurrence_rule` (VARCHAR) - RFC 5545 RRULE
- `subtasks` (JSONB) - Subtask array
- `metadata` (JSONB) - Extra data
### labels
- `id` (UUID) - Primary key
- `user_id` (UUID) - Owner
- `name` (VARCHAR) - Label name
- `color` (VARCHAR) - Hex color
### task_labels
- `task_id` (UUID) - FK to tasks
- `label_id` (UUID) - FK to labels
### reminders
- `id` (UUID) - Primary key
- `task_id` (UUID) - FK to tasks
- `minutes_before` (INTEGER) - Offset
- `type` (VARCHAR) - push/email/both
- `status` (VARCHAR) - pending/sent/failed
## Environment Variables
### Backend (.env)
```env
NODE_ENV=development
PORT=3018
DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/todo
MANA_CORE_AUTH_URL=http://localhost:3001
CORS_ORIGINS=http://localhost:5173,http://localhost:5186,http://localhost:8081
```
### Web (.env)
```env
PUBLIC_BACKEND_URL=http://localhost:3018
PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
```
## Quick Add Syntax
Natural language task creation:
```
"Meeting morgen um 14 Uhr !hoch @Arbeit #wichtig"
```
Recognized patterns:
- **Date**: heute, morgen, nächsten Montag, 15.12.
- **Time**: um 14 Uhr, 14:00
- **Priority**: !hoch, !niedrig, !dringend, !!!
- **Project**: @Projektname
- **Labels**: #label1 #label2
- **Recurrence**: jeden Tag, wöchentlich, monatlich
## Code Style Guidelines
- **TypeScript**: Strict typing with interfaces
- **Web**: Svelte 5 runes mode (`$state`, `$derived`, `$effect`)
- **Styling**: Tailwind CSS with CSS variables
- **Formatting**: Prettier with project config
- **i18n**: All UI text in locale files
## Important Notes
1. **Authentication**: Uses Mana Core Auth (JWT in Authorization header)
2. **Database**: PostgreSQL with Drizzle ORM (port 5432)
3. **Ports**: Backend=3018, Web=5188, Landing=4323
4. **Recurrence**: Uses RFC 5545 RRULE format
5. **Calendar**: Tasks can sync bidirectionally with Calendar app

View file

@ -0,0 +1,13 @@
import { defineConfig } from 'drizzle-kit';
import * as dotenv from 'dotenv';
dotenv.config();
export default defineConfig({
schema: './src/db/schema/index.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});

View file

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View file

@ -0,0 +1,42 @@
{
"name": "@todo/backend",
"version": "1.0.0",
"private": true,
"description": "Todo Backend API",
"scripts": {
"dev": "nest start --watch",
"build": "nest build",
"start": "nest start",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:seed": "tsx src/db/seed.ts",
"db:generate": "drizzle-kit generate"
},
"dependencies": {
"@todo/shared": "workspace:*",
"@manacore/shared-nestjs-auth": "workspace:*",
"@nestjs/common": "^10.4.9",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.9",
"@nestjs/platform-express": "^10.4.9",
"@nestjs/schedule": "^4.1.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^16.4.7",
"drizzle-orm": "^0.38.3",
"postgres": "^3.4.5",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@types/express": "^5.0.1",
"@types/node": "^22.15.21",
"drizzle-kit": "^0.30.2",
"tsx": "^4.19.4",
"typescript": "^5.9.3"
}
}

View file

@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { DatabaseModule } from './db/database.module';
import { HealthModule } from './health/health.module';
import { ProjectModule } from './project/project.module';
import { TaskModule } from './task/task.module';
import { LabelModule } from './label/label.module';
import { ReminderModule } from './reminder/reminder.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
ScheduleModule.forRoot(),
DatabaseModule,
HealthModule,
ProjectModule,
TaskModule,
LabelModule,
ReminderModule,
],
})
export class AppModule {}

View file

@ -0,0 +1,38 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
let db: ReturnType<typeof drizzle<typeof schema>> | null = null;
let connection: ReturnType<typeof postgres> | null = null;
export function getDb(connectionString?: string) {
if (db) return db;
const url = connectionString || process.env.DATABASE_URL;
if (!url) {
throw new Error('DATABASE_URL is not defined');
}
connection = postgres(url, {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
});
db = drizzle(connection, { schema });
return db;
}
export function getConnection() {
return connection;
}
export async function closeConnection() {
if (connection) {
await connection.end();
connection = null;
db = null;
}
}
export type Database = ReturnType<typeof getDb>;

View file

@ -0,0 +1,21 @@
import { Global, Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { getDb } from './connection';
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
@Global()
@Module({
providers: [
{
provide: DATABASE_CONNECTION,
useFactory: (configService: ConfigService) => {
const databaseUrl = configService.get<string>('DATABASE_URL');
return getDb(databaseUrl);
},
inject: [ConfigService],
},
],
exports: [DATABASE_CONNECTION],
})
export class DatabaseModule {}

View file

@ -0,0 +1,5 @@
export * from './projects.schema';
export * from './tasks.schema';
export * from './labels.schema';
export * from './task-labels.schema';
export * from './reminders.schema';

View file

@ -0,0 +1,19 @@
import { pgTable, uuid, timestamp, varchar, index } from 'drizzle-orm/pg-core';
export const labels = pgTable(
'labels',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
name: varchar('name', { length: 100 }).notNull(),
color: varchar('color', { length: 7 }).default('#6B7280'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
userIdx: index('labels_user_idx').on(table.userId),
})
);
export type Label = typeof labels.$inferSelect;
export type NewLabel = typeof labels.$inferInsert;

View file

@ -0,0 +1,43 @@
import {
pgTable,
uuid,
timestamp,
varchar,
text,
boolean,
integer,
jsonb,
index,
} from 'drizzle-orm/pg-core';
export interface ProjectSettings {
defaultView?: 'list' | 'board';
showCompletedTasks?: boolean;
sortBy?: 'dueDate' | 'priority' | 'createdAt' | 'order';
sortOrder?: 'asc' | 'desc';
}
export const projects = pgTable(
'projects',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
name: varchar('name', { length: 255 }).notNull(),
description: text('description'),
color: varchar('color', { length: 7 }).default('#3B82F6'),
icon: varchar('icon', { length: 50 }),
order: integer('order').default(0),
isArchived: boolean('is_archived').default(false),
isDefault: boolean('is_default').default(false),
settings: jsonb('settings').$type<ProjectSettings>(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
userIdx: index('projects_user_idx').on(table.userId),
orderIdx: index('projects_order_idx').on(table.userId, table.order),
})
);
export type Project = typeof projects.$inferSelect;
export type NewProject = typeof projects.$inferInsert;

View file

@ -0,0 +1,37 @@
import { pgTable, uuid, timestamp, varchar, integer, index } from 'drizzle-orm/pg-core';
import { tasks } from './tasks.schema';
export type ReminderType = 'push' | 'email' | 'both';
export type ReminderStatus = 'pending' | 'sent' | 'failed' | 'cancelled';
export const reminders = pgTable(
'reminders',
{
id: uuid('id').primaryKey().defaultRandom(),
taskId: uuid('task_id')
.notNull()
.references(() => tasks.id, { onDelete: 'cascade' }),
userId: uuid('user_id').notNull(),
// Timing
minutesBefore: integer('minutes_before').notNull(),
reminderTime: timestamp('reminder_time', { withTimezone: true }).notNull(),
// Type
type: varchar('type', { length: 20 }).default('push').$type<ReminderType>(),
// Status
status: varchar('status', { length: 20 }).default('pending').$type<ReminderStatus>(),
sentAt: timestamp('sent_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
taskIdx: index('reminders_task_idx').on(table.taskId),
userIdx: index('reminders_user_idx').on(table.userId),
pendingIdx: index('reminders_pending_idx').on(table.status, table.reminderTime),
})
);
export type Reminder = typeof reminders.$inferSelect;
export type NewReminder = typeof reminders.$inferInsert;

View file

@ -0,0 +1,23 @@
import { pgTable, uuid, primaryKey, index } from 'drizzle-orm/pg-core';
import { tasks } from './tasks.schema';
import { labels } from './labels.schema';
export const taskLabels = pgTable(
'task_labels',
{
taskId: uuid('task_id')
.notNull()
.references(() => tasks.id, { onDelete: 'cascade' }),
labelId: uuid('label_id')
.notNull()
.references(() => labels.id, { onDelete: 'cascade' }),
},
(table) => ({
pk: primaryKey({ columns: [table.taskId, table.labelId] }),
taskIdx: index('task_labels_task_idx').on(table.taskId),
labelIdx: index('task_labels_label_idx').on(table.labelId),
})
);
export type TaskLabel = typeof taskLabels.$inferSelect;
export type NewTaskLabel = typeof taskLabels.$inferInsert;

View file

@ -0,0 +1,84 @@
import {
pgTable,
uuid,
timestamp,
varchar,
text,
boolean,
integer,
jsonb,
index,
} from 'drizzle-orm/pg-core';
import { projects } from './projects.schema';
export type TaskPriority = 'low' | 'medium' | 'high' | 'urgent';
export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled';
export interface Subtask {
id: string;
title: string;
isCompleted: boolean;
completedAt?: string | null;
order: number;
}
export interface TaskMetadata {
notes?: string;
attachments?: string[];
linkedCalendarEventId?: string | null;
}
export const tasks = pgTable(
'tasks',
{
id: uuid('id').primaryKey().defaultRandom(),
projectId: uuid('project_id').references(() => projects.id, { onDelete: 'set null' }),
userId: uuid('user_id').notNull(),
parentTaskId: uuid('parent_task_id'),
// Content
title: varchar('title', { length: 500 }).notNull(),
description: text('description'),
// Scheduling
dueDate: timestamp('due_date', { withTimezone: true }),
dueTime: varchar('due_time', { length: 5 }),
startDate: timestamp('start_date', { withTimezone: true }),
// Priority & Status
priority: varchar('priority', { length: 10 }).default('medium').$type<TaskPriority>(),
status: varchar('status', { length: 20 }).default('pending').$type<TaskStatus>(),
// Completion
isCompleted: boolean('is_completed').default(false),
completedAt: timestamp('completed_at', { withTimezone: true }),
// Ordering
order: integer('order').default(0),
// Recurrence (RFC 5545 RRULE format)
recurrenceRule: varchar('recurrence_rule', { length: 500 }),
recurrenceEndDate: timestamp('recurrence_end_date', { withTimezone: true }),
lastOccurrence: timestamp('last_occurrence', { withTimezone: true }),
// Subtasks stored as JSONB
subtasks: jsonb('subtasks').$type<Subtask[]>(),
// Metadata
metadata: jsonb('metadata').$type<TaskMetadata>(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
projectIdx: index('tasks_project_idx').on(table.projectId),
userIdx: index('tasks_user_idx').on(table.userId),
dueDateIdx: index('tasks_due_date_idx').on(table.dueDate),
statusIdx: index('tasks_status_idx').on(table.isCompleted, table.status),
parentIdx: index('tasks_parent_idx').on(table.parentTaskId),
orderIdx: index('tasks_order_idx').on(table.projectId, table.order),
})
);
export type Task = typeof tasks.$inferSelect;
export type NewTask = typeof tasks.$inferInsert;

View file

@ -0,0 +1,13 @@
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
check() {
return {
status: 'ok',
service: 'todo-backend',
timestamp: new Date().toISOString(),
};
}
}

View file

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View file

@ -0,0 +1,12 @@
import { IsString, IsOptional, MaxLength } from 'class-validator';
export class CreateLabelDto {
@IsString()
@MaxLength(100)
name: string;
@IsOptional()
@IsString()
@MaxLength(7)
color?: string;
}

View file

@ -0,0 +1,2 @@
export * from './create-label.dto';
export * from './update-label.dto';

View file

@ -0,0 +1,13 @@
import { IsString, IsOptional, MaxLength } from 'class-validator';
export class UpdateLabelDto {
@IsOptional()
@IsString()
@MaxLength(100)
name?: string;
@IsOptional()
@IsString()
@MaxLength(7)
color?: string;
}

View file

@ -0,0 +1,44 @@
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { LabelService } from './label.service';
import { CreateLabelDto, UpdateLabelDto } from './dto';
@Controller('labels')
@UseGuards(JwtAuthGuard)
export class LabelController {
constructor(private readonly labelService: LabelService) {}
@Get()
async findAll(@CurrentUser() user: CurrentUserData) {
const labels = await this.labelService.findAll(user.userId);
return { labels };
}
@Get(':id')
async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
const label = await this.labelService.findByIdOrThrow(id, user.userId);
return { label };
}
@Post()
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateLabelDto) {
const label = await this.labelService.create(user.userId, dto);
return { label };
}
@Put(':id')
async update(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: UpdateLabelDto
) {
const label = await this.labelService.update(id, user.userId, dto);
return { label };
}
@Delete(':id')
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
await this.labelService.delete(id, user.userId);
return { success: true };
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { LabelController } from './label.controller';
import { LabelService } from './label.service';
@Module({
controllers: [LabelController],
providers: [LabelService],
exports: [LabelService],
})
export class LabelModule {}

View file

@ -0,0 +1,64 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { eq, and, asc } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { labels, type Label, type NewLabel } from '../db/schema';
import { CreateLabelDto, UpdateLabelDto } from './dto';
@Injectable()
export class LabelService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
async findAll(userId: string): Promise<Label[]> {
return this.db.query.labels.findMany({
where: eq(labels.userId, userId),
orderBy: [asc(labels.name)],
});
}
async findById(id: string, userId: string): Promise<Label | null> {
const result = await this.db.query.labels.findFirst({
where: and(eq(labels.id, id), eq(labels.userId, userId)),
});
return result ?? null;
}
async findByIdOrThrow(id: string, userId: string): Promise<Label> {
const label = await this.findById(id, userId);
if (!label) {
throw new NotFoundException(`Label with id ${id} not found`);
}
return label;
}
async create(userId: string, dto: CreateLabelDto): Promise<Label> {
const newLabel: NewLabel = {
userId,
name: dto.name,
color: dto.color ?? '#6B7280',
};
const [created] = await this.db.insert(labels).values(newLabel).returning();
return created;
}
async update(id: string, userId: string, dto: UpdateLabelDto): Promise<Label> {
await this.findByIdOrThrow(id, userId);
const [updated] = await this.db
.update(labels)
.set({
...dto,
updatedAt: new Date(),
})
.where(and(eq(labels.id, id), eq(labels.userId, userId)))
.returning();
return updated;
}
async delete(id: string, userId: string): Promise<void> {
await this.findByIdOrThrow(id, userId);
await this.db.delete(labels).where(and(eq(labels.id, id), eq(labels.userId, userId)));
}
}

View file

@ -0,0 +1,65 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, Logger } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const logger = new Logger('Bootstrap');
const app = await NestFactory.create(AppModule);
// Enable CORS for all platforms
app.enableCors({
origin: (origin, callback) => {
// Allow requests with no origin (mobile apps, curl, etc.)
if (!origin) {
callback(null, true);
return;
}
const allowedOrigins = process.env.CORS_ORIGINS?.split(',') || [
'http://localhost:5173',
'http://localhost:5186',
'http://localhost:8081',
'http://localhost:19006',
];
// Allow all localhost ports in development
if (process.env.NODE_ENV === 'development' && origin.includes('localhost')) {
callback(null, true);
return;
}
if (allowedOrigins.includes(origin)) {
callback(null, true);
} else {
logger.warn(`Blocked request from origin: ${origin}`);
callback(new Error('Not allowed by CORS'), false);
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
});
// Global validation pipe
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: {
enableImplicitConversion: true,
},
})
);
// API prefix
app.setGlobalPrefix('api/v1');
const port = process.env.PORT || 3017;
await app.listen(port);
logger.log(`Todo API is running on: http://localhost:${port}`);
logger.log(`Health check: http://localhost:${port}/api/v1/health`);
}
bootstrap();

View file

@ -0,0 +1,30 @@
import { IsString, IsOptional, IsBoolean, MaxLength, IsObject } from 'class-validator';
import type { ProjectSettings } from '../../db/schema/projects.schema';
export class CreateProjectDto {
@IsString()
@MaxLength(255)
name: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsString()
@MaxLength(7)
color?: string;
@IsOptional()
@IsString()
@MaxLength(50)
icon?: string;
@IsOptional()
@IsBoolean()
isDefault?: boolean;
@IsOptional()
@IsObject()
settings?: ProjectSettings;
}

View file

@ -0,0 +1,3 @@
export * from './create-project.dto';
export * from './update-project.dto';
export * from './reorder-projects.dto';

View file

@ -0,0 +1,7 @@
import { IsArray, IsUUID } from 'class-validator';
export class ReorderProjectsDto {
@IsArray()
@IsUUID('4', { each: true })
projectIds: string[];
}

View file

@ -0,0 +1,35 @@
import { IsString, IsOptional, IsBoolean, MaxLength, IsObject } from 'class-validator';
import type { ProjectSettings } from '../../db/schema/projects.schema';
export class UpdateProjectDto {
@IsOptional()
@IsString()
@MaxLength(255)
name?: string;
@IsOptional()
@IsString()
description?: string | null;
@IsOptional()
@IsString()
@MaxLength(7)
color?: string;
@IsOptional()
@IsString()
@MaxLength(50)
icon?: string | null;
@IsOptional()
@IsBoolean()
isArchived?: boolean;
@IsOptional()
@IsBoolean()
isDefault?: boolean;
@IsOptional()
@IsObject()
settings?: ProjectSettings | null;
}

View file

@ -0,0 +1,64 @@
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { ProjectService } from './project.service';
import { CreateProjectDto, UpdateProjectDto, ReorderProjectsDto } from './dto';
@Controller('projects')
@UseGuards(JwtAuthGuard)
export class ProjectController {
constructor(private readonly projectService: ProjectService) {}
@Get()
async findAll(@CurrentUser() user: CurrentUserData) {
// Ensure user has at least an inbox project
await this.projectService.getOrCreateDefaultProject(user.userId);
const projects = await this.projectService.findAll(user.userId);
return { projects };
}
@Get(':id')
async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
const project = await this.projectService.findByIdOrThrow(id, user.userId);
return { project };
}
@Post()
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateProjectDto) {
const project = await this.projectService.create(user.userId, dto);
return { project };
}
@Put(':id')
async update(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: UpdateProjectDto
) {
const project = await this.projectService.update(id, user.userId, dto);
return { project };
}
@Delete(':id')
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
await this.projectService.delete(id, user.userId);
return { success: true };
}
@Post(':id/archive')
async archive(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
const project = await this.projectService.archive(id, user.userId);
return { project };
}
@Post(':id/unarchive')
async unarchive(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
const project = await this.projectService.unarchive(id, user.userId);
return { project };
}
@Put('reorder')
async reorder(@CurrentUser() user: CurrentUserData, @Body() dto: ReorderProjectsDto) {
const projects = await this.projectService.reorder(user.userId, dto.projectIds);
return { projects };
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ProjectController } from './project.controller';
import { ProjectService } from './project.service';
@Module({
controllers: [ProjectController],
providers: [ProjectService],
exports: [ProjectService],
})
export class ProjectModule {}

View file

@ -0,0 +1,140 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { eq, and, asc } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { projects, type Project, type NewProject } from '../db/schema';
import { CreateProjectDto, UpdateProjectDto } from './dto';
@Injectable()
export class ProjectService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
async findAll(userId: string): Promise<Project[]> {
return this.db.query.projects.findMany({
where: eq(projects.userId, userId),
orderBy: [asc(projects.order), asc(projects.createdAt)],
});
}
async findById(id: string, userId: string): Promise<Project | null> {
const result = await this.db.query.projects.findFirst({
where: and(eq(projects.id, id), eq(projects.userId, userId)),
});
return result ?? null;
}
async findByIdOrThrow(id: string, userId: string): Promise<Project> {
const project = await this.findById(id, userId);
if (!project) {
throw new NotFoundException(`Project with id ${id} not found`);
}
return project;
}
async create(userId: string, dto: CreateProjectDto): Promise<Project> {
// Get the highest order value
const existingProjects = await this.findAll(userId);
const maxOrder = existingProjects.reduce((max, p) => Math.max(max, p.order ?? 0), -1);
// If this is the first project, make it default
const isDefault = dto.isDefault ?? existingProjects.length === 0;
// If this project is default, clear other defaults
if (isDefault) {
await this.clearDefaultProject(userId);
}
const newProject: NewProject = {
userId,
name: dto.name,
description: dto.description,
color: dto.color ?? '#3B82F6',
icon: dto.icon,
order: maxOrder + 1,
isDefault,
settings: dto.settings,
};
const [created] = await this.db.insert(projects).values(newProject).returning();
return created;
}
async update(id: string, userId: string, dto: UpdateProjectDto): Promise<Project> {
await this.findByIdOrThrow(id, userId);
// If setting as default, clear other defaults first
if (dto.isDefault) {
await this.clearDefaultProject(userId);
}
const [updated] = await this.db
.update(projects)
.set({
...dto,
updatedAt: new Date(),
})
.where(and(eq(projects.id, id), eq(projects.userId, userId)))
.returning();
return updated;
}
async delete(id: string, userId: string): Promise<void> {
const project = await this.findByIdOrThrow(id, userId);
// Don't allow deleting the default inbox project
if (project.isDefault) {
throw new NotFoundException('Cannot delete the default inbox project');
}
await this.db.delete(projects).where(and(eq(projects.id, id), eq(projects.userId, userId)));
}
async archive(id: string, userId: string): Promise<Project> {
return this.update(id, userId, { isArchived: true });
}
async unarchive(id: string, userId: string): Promise<Project> {
return this.update(id, userId, { isArchived: false });
}
async reorder(userId: string, projectIds: string[]): Promise<Project[]> {
// Update order for each project
const updates = projectIds.map((id, index) =>
this.db
.update(projects)
.set({ order: index, updatedAt: new Date() })
.where(and(eq(projects.id, id), eq(projects.userId, userId)))
);
await Promise.all(updates);
return this.findAll(userId);
}
async getOrCreateDefaultProject(userId: string): Promise<Project> {
// Try to find existing default project
const defaultProject = await this.db.query.projects.findFirst({
where: and(eq(projects.userId, userId), eq(projects.isDefault, true)),
});
if (defaultProject) {
return defaultProject;
}
// Create default inbox project
return this.create(userId, {
name: 'Inbox',
color: '#6B7280',
icon: 'inbox',
isDefault: true,
});
}
private async clearDefaultProject(userId: string): Promise<void> {
await this.db
.update(projects)
.set({ isDefault: false, updatedAt: new Date() })
.where(and(eq(projects.userId, userId), eq(projects.isDefault, true)));
}
}

View file

@ -0,0 +1,12 @@
import { IsNumber, IsOptional, IsEnum, Min } from 'class-validator';
import type { ReminderType } from '../../db/schema/reminders.schema';
export class CreateReminderDto {
@IsNumber()
@Min(0)
minutesBefore: number;
@IsOptional()
@IsEnum(['push', 'email', 'both'])
type?: ReminderType;
}

View file

@ -0,0 +1 @@
export * from './create-reminder.dto';

View file

@ -0,0 +1,32 @@
import { Controller, Get, Post, Delete, Body, Param, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { ReminderService } from './reminder.service';
import { CreateReminderDto } from './dto';
@Controller()
@UseGuards(JwtAuthGuard)
export class ReminderController {
constructor(private readonly reminderService: ReminderService) {}
@Get('tasks/:taskId/reminders')
async findByTask(@CurrentUser() user: CurrentUserData, @Param('taskId') taskId: string) {
const reminders = await this.reminderService.findByTask(taskId, user.userId);
return { reminders };
}
@Post('tasks/:taskId/reminders')
async create(
@CurrentUser() user: CurrentUserData,
@Param('taskId') taskId: string,
@Body() dto: CreateReminderDto
) {
const reminder = await this.reminderService.create(taskId, user.userId, dto);
return { reminder };
}
@Delete('reminders/:id')
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
await this.reminderService.delete(id, user.userId);
return { success: true };
}
}

View file

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ReminderController } from './reminder.controller';
import { ReminderService } from './reminder.service';
import { TaskModule } from '../task/task.module';
@Module({
imports: [TaskModule],
controllers: [ReminderController],
providers: [ReminderService],
exports: [ReminderService],
})
export class ReminderModule {}

View file

@ -0,0 +1,75 @@
import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common';
import { eq, and, asc } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { reminders, type Reminder, type NewReminder } from '../db/schema';
import { TaskService } from '../task/task.service';
import { CreateReminderDto } from './dto';
@Injectable()
export class ReminderService {
constructor(
@Inject(DATABASE_CONNECTION) private db: Database,
private taskService: TaskService
) {}
async findByTask(taskId: string, userId: string): Promise<Reminder[]> {
// Verify task belongs to user
await this.taskService.findByIdOrThrow(taskId, userId);
return this.db.query.reminders.findMany({
where: and(eq(reminders.taskId, taskId), eq(reminders.userId, userId)),
orderBy: [asc(reminders.minutesBefore)],
});
}
async findById(id: string, userId: string): Promise<Reminder | null> {
const result = await this.db.query.reminders.findFirst({
where: and(eq(reminders.id, id), eq(reminders.userId, userId)),
});
return result ?? null;
}
async findByIdOrThrow(id: string, userId: string): Promise<Reminder> {
const reminder = await this.findById(id, userId);
if (!reminder) {
throw new NotFoundException(`Reminder with id ${id} not found`);
}
return reminder;
}
async create(taskId: string, userId: string, dto: CreateReminderDto): Promise<Reminder> {
// Verify task belongs to user and get due date
const task = await this.taskService.findByIdOrThrow(taskId, userId);
if (!task.dueDate) {
throw new BadRequestException('Cannot create reminder for task without due date');
}
// Calculate reminder time
const dueDate = new Date(task.dueDate);
const reminderTime = new Date(dueDate.getTime() - dto.minutesBefore * 60 * 1000);
const newReminder: NewReminder = {
taskId,
userId,
minutesBefore: dto.minutesBefore,
reminderTime,
type: dto.type ?? 'push',
};
const [created] = await this.db.insert(reminders).values(newReminder).returning();
return created;
}
async delete(id: string, userId: string): Promise<void> {
await this.findByIdOrThrow(id, userId);
await this.db.delete(reminders).where(and(eq(reminders.id, id), eq(reminders.userId, userId)));
}
async deleteByTask(taskId: string, userId: string): Promise<void> {
await this.db
.delete(reminders)
.where(and(eq(reminders.taskId, taskId), eq(reminders.userId, userId)));
}
}

View file

@ -0,0 +1,67 @@
import {
IsString,
IsOptional,
IsUUID,
IsEnum,
IsArray,
IsObject,
MaxLength,
IsDateString,
} from 'class-validator';
import type { TaskPriority, Subtask, TaskMetadata } from '../../db/schema/tasks.schema';
export class CreateTaskDto {
@IsString()
@MaxLength(500)
title: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsUUID()
projectId?: string | null;
@IsOptional()
@IsUUID()
parentTaskId?: string | null;
@IsOptional()
@IsDateString()
dueDate?: string | null;
@IsOptional()
@IsString()
@MaxLength(5)
dueTime?: string | null;
@IsOptional()
@IsDateString()
startDate?: string | null;
@IsOptional()
@IsEnum(['low', 'medium', 'high', 'urgent'])
priority?: TaskPriority;
@IsOptional()
@IsString()
recurrenceRule?: string | null;
@IsOptional()
@IsDateString()
recurrenceEndDate?: string | null;
@IsOptional()
@IsArray()
subtasks?: Omit<Subtask, 'id'>[];
@IsOptional()
@IsArray()
@IsUUID('4', { each: true })
labelIds?: string[];
@IsOptional()
@IsObject()
metadata?: TaskMetadata;
}

View file

@ -0,0 +1,3 @@
export * from './create-task.dto';
export * from './update-task.dto';
export * from './query-tasks.dto';

View file

@ -0,0 +1,64 @@
import {
IsOptional,
IsUUID,
IsEnum,
IsBoolean,
IsString,
IsNumber,
IsDateString,
} from 'class-validator';
import { Transform } from 'class-transformer';
import type { TaskPriority, TaskStatus } from '../../db/schema/tasks.schema';
export class QueryTasksDto {
@IsOptional()
@IsUUID()
projectId?: string;
@IsOptional()
@IsUUID()
labelId?: string;
@IsOptional()
@IsEnum(['low', 'medium', 'high', 'urgent'])
priority?: TaskPriority;
@IsOptional()
@IsEnum(['pending', 'in_progress', 'completed', 'cancelled'])
status?: TaskStatus;
@IsOptional()
@Transform(({ value }) => value === 'true' || value === true)
@IsBoolean()
isCompleted?: boolean;
@IsOptional()
@IsDateString()
dueDateFrom?: string;
@IsOptional()
@IsDateString()
dueDateTo?: string;
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsEnum(['dueDate', 'priority', 'createdAt', 'order'])
sortBy?: 'dueDate' | 'priority' | 'createdAt' | 'order';
@IsOptional()
@IsEnum(['asc', 'desc'])
sortOrder?: 'asc' | 'desc';
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
@IsNumber()
limit?: number;
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
@IsNumber()
offset?: number;
}

View file

@ -0,0 +1,77 @@
import {
IsString,
IsOptional,
IsUUID,
IsEnum,
IsBoolean,
IsNumber,
IsArray,
IsObject,
MaxLength,
IsDateString,
} from 'class-validator';
import type { TaskPriority, TaskStatus, Subtask, TaskMetadata } from '../../db/schema/tasks.schema';
export class UpdateTaskDto {
@IsOptional()
@IsString()
@MaxLength(500)
title?: string;
@IsOptional()
@IsString()
description?: string | null;
@IsOptional()
@IsUUID()
projectId?: string | null;
@IsOptional()
@IsUUID()
parentTaskId?: string | null;
@IsOptional()
@IsDateString()
dueDate?: string | null;
@IsOptional()
@IsString()
@MaxLength(5)
dueTime?: string | null;
@IsOptional()
@IsDateString()
startDate?: string | null;
@IsOptional()
@IsEnum(['low', 'medium', 'high', 'urgent'])
priority?: TaskPriority;
@IsOptional()
@IsEnum(['pending', 'in_progress', 'completed', 'cancelled'])
status?: TaskStatus;
@IsOptional()
@IsBoolean()
isCompleted?: boolean;
@IsOptional()
@IsNumber()
order?: number;
@IsOptional()
@IsString()
recurrenceRule?: string | null;
@IsOptional()
@IsDateString()
recurrenceEndDate?: string | null;
@IsOptional()
@IsArray()
subtasks?: Subtask[] | null;
@IsOptional()
@IsObject()
metadata?: TaskMetadata | null;
}

View file

@ -0,0 +1,111 @@
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { TaskService } from './task.service';
import { CreateTaskDto, UpdateTaskDto, QueryTasksDto } from './dto';
@Controller('tasks')
@UseGuards(JwtAuthGuard)
export class TaskController {
constructor(private readonly taskService: TaskService) {}
@Get()
async findAll(@CurrentUser() user: CurrentUserData, @Query() query: QueryTasksDto) {
const tasks = await this.taskService.findAll(user.userId, query);
return { tasks };
}
@Get('inbox')
async getInbox(@CurrentUser() user: CurrentUserData) {
const tasks = await this.taskService.getInboxTasks(user.userId);
return { tasks };
}
@Get('today')
async getToday(@CurrentUser() user: CurrentUserData) {
const tasks = await this.taskService.getTodayTasks(user.userId);
return { tasks };
}
@Get('upcoming')
async getUpcoming(@CurrentUser() user: CurrentUserData, @Query('days') days?: number) {
const tasks = await this.taskService.getUpcomingTasks(user.userId, days ?? 7);
return { tasks };
}
@Get('completed')
async getCompleted(@CurrentUser() user: CurrentUserData, @Query('limit') limit?: number) {
const tasks = await this.taskService.getCompletedTasks(user.userId, limit ?? 50);
return { tasks };
}
@Get(':id')
async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
const task = await this.taskService.findByIdOrThrow(id, user.userId);
return { task };
}
@Post()
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateTaskDto) {
const task = await this.taskService.create(user.userId, dto);
return { task };
}
@Put(':id')
async update(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: UpdateTaskDto
) {
const task = await this.taskService.update(id, user.userId, dto);
return { task };
}
@Delete(':id')
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
await this.taskService.delete(id, user.userId);
return { success: true };
}
@Post(':id/complete')
async complete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
const task = await this.taskService.complete(id, user.userId);
return { task };
}
@Post(':id/uncomplete')
async uncomplete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
const task = await this.taskService.uncomplete(id, user.userId);
return { task };
}
@Post(':id/move')
async move(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body('projectId') projectId: string | null
) {
const task = await this.taskService.move(id, user.userId, projectId);
return { task };
}
@Put(':id/labels')
async updateLabels(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body('labelIds') labelIds: string[]
) {
await this.taskService.updateTaskLabels(id, user.userId, labelIds);
const task = await this.taskService.findByIdOrThrow(id, user.userId);
return { task };
}
@Put('reorder')
async reorder(
@CurrentUser() user: CurrentUserData,
@Body('taskIds') taskIds: string[],
@Body('projectId') projectId?: string | null
) {
const tasks = await this.taskService.reorder(user.userId, taskIds, projectId);
return { tasks };
}
}

View file

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TaskController } from './task.controller';
import { TaskService } from './task.service';
import { ProjectModule } from '../project/project.module';
@Module({
imports: [ProjectModule],
controllers: [TaskController],
providers: [TaskService],
exports: [TaskService],
})
export class TaskModule {}

View file

@ -0,0 +1,337 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { eq, and, or, gte, lte, ilike, asc, desc, isNull, SQL } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { tasks, taskLabels, labels, type Task, type NewTask, type Subtask } from '../db/schema';
import { ProjectService } from '../project/project.service';
import { CreateTaskDto, UpdateTaskDto, QueryTasksDto } from './dto';
@Injectable()
export class TaskService {
constructor(
@Inject(DATABASE_CONNECTION) private db: Database,
private projectService: ProjectService
) {}
async findAll(userId: string, query: QueryTasksDto = {}): Promise<Task[]> {
const conditions: SQL[] = [eq(tasks.userId, userId)];
if (query.projectId) {
conditions.push(eq(tasks.projectId, query.projectId));
}
if (query.priority) {
conditions.push(eq(tasks.priority, query.priority));
}
if (query.status) {
conditions.push(eq(tasks.status, query.status));
}
if (query.isCompleted !== undefined) {
conditions.push(eq(tasks.isCompleted, query.isCompleted));
}
if (query.dueDateFrom) {
conditions.push(gte(tasks.dueDate, new Date(query.dueDateFrom)));
}
if (query.dueDateTo) {
conditions.push(lte(tasks.dueDate, new Date(query.dueDateTo)));
}
if (query.search) {
conditions.push(
or(
ilike(tasks.title, `%${query.search}%`),
ilike(tasks.description, `%${query.search}%`)
) as SQL
);
}
// Build order by clause
let orderBy: SQL[];
switch (query.sortBy) {
case 'dueDate':
orderBy = query.sortOrder === 'desc' ? [desc(tasks.dueDate)] : [asc(tasks.dueDate)];
break;
case 'priority':
// Priority order: urgent > high > medium > low
orderBy = query.sortOrder === 'desc' ? [asc(tasks.priority)] : [desc(tasks.priority)];
break;
case 'createdAt':
orderBy = query.sortOrder === 'desc' ? [desc(tasks.createdAt)] : [asc(tasks.createdAt)];
break;
default:
orderBy = [asc(tasks.order), asc(tasks.createdAt)];
}
const result = await this.db.query.tasks.findMany({
where: and(...conditions),
orderBy,
limit: query.limit,
offset: query.offset,
});
// Load labels for each task
return Promise.all(result.map((task) => this.loadTaskLabels(task)));
}
async findById(id: string, userId: string): Promise<Task | null> {
const result = await this.db.query.tasks.findFirst({
where: and(eq(tasks.id, id), eq(tasks.userId, userId)),
});
if (!result) return null;
return this.loadTaskLabels(result);
}
async findByIdOrThrow(id: string, userId: string): Promise<Task> {
const task = await this.findById(id, userId);
if (!task) {
throw new NotFoundException(`Task with id ${id} not found`);
}
return task;
}
async create(userId: string, dto: CreateTaskDto): Promise<Task> {
// Verify project belongs to user if provided
if (dto.projectId) {
await this.projectService.findByIdOrThrow(dto.projectId, userId);
}
// Get the highest order value for the project
const existingTasks = await this.findAll(userId, { projectId: dto.projectId ?? undefined });
const maxOrder = existingTasks.reduce((max, t) => Math.max(max, t.order ?? 0), -1);
// Generate IDs for subtasks
const subtasksWithIds: Subtask[] | undefined = dto.subtasks?.map((s, i) => ({
id: `subtask_${Date.now()}_${i}`,
title: s.title,
isCompleted: s.isCompleted ?? false,
order: s.order ?? i,
}));
const newTask: NewTask = {
userId,
projectId: dto.projectId,
parentTaskId: dto.parentTaskId,
title: dto.title,
description: dto.description,
dueDate: dto.dueDate ? new Date(dto.dueDate) : null,
dueTime: dto.dueTime,
startDate: dto.startDate ? new Date(dto.startDate) : null,
priority: dto.priority ?? 'medium',
recurrenceRule: dto.recurrenceRule,
recurrenceEndDate: dto.recurrenceEndDate ? new Date(dto.recurrenceEndDate) : null,
subtasks: subtasksWithIds,
metadata: dto.metadata,
order: maxOrder + 1,
};
const [created] = await this.db.insert(tasks).values(newTask).returning();
// Add labels if provided
if (dto.labelIds?.length) {
await this.updateTaskLabels(created.id, userId, dto.labelIds);
}
return this.loadTaskLabels(created);
}
async update(id: string, userId: string, dto: UpdateTaskDto): Promise<Task> {
await this.findByIdOrThrow(id, userId);
// Verify project belongs to user if changing project
if (dto.projectId) {
await this.projectService.findByIdOrThrow(dto.projectId, userId);
}
const updateData: Partial<NewTask> = {
...dto,
dueDate: dto.dueDate ? new Date(dto.dueDate) : dto.dueDate === null ? null : undefined,
startDate: dto.startDate
? new Date(dto.startDate)
: dto.startDate === null
? null
: undefined,
recurrenceEndDate: dto.recurrenceEndDate
? new Date(dto.recurrenceEndDate)
: dto.recurrenceEndDate === null
? null
: undefined,
completedAt: dto.isCompleted ? new Date() : dto.isCompleted === false ? null : undefined,
updatedAt: new Date(),
};
// Remove undefined values
Object.keys(updateData).forEach((key) => {
if (updateData[key as keyof typeof updateData] === undefined) {
delete updateData[key as keyof typeof updateData];
}
});
const [updated] = await this.db
.update(tasks)
.set(updateData)
.where(and(eq(tasks.id, id), eq(tasks.userId, userId)))
.returning();
return this.loadTaskLabels(updated);
}
async delete(id: string, userId: string): Promise<void> {
await this.findByIdOrThrow(id, userId);
await this.db.delete(tasks).where(and(eq(tasks.id, id), eq(tasks.userId, userId)));
}
async complete(id: string, userId: string): Promise<Task> {
const task = await this.findByIdOrThrow(id, userId);
// If task has recurrence, create next occurrence instead of completing
if (task.recurrenceRule) {
// TODO: Implement recurrence handling
// For now, just mark as complete
}
return this.update(id, userId, {
isCompleted: true,
status: 'completed',
});
}
async uncomplete(id: string, userId: string): Promise<Task> {
return this.update(id, userId, {
isCompleted: false,
status: 'pending',
});
}
async move(id: string, userId: string, projectId: string | null): Promise<Task> {
// Verify new project if provided
if (projectId) {
await this.projectService.findByIdOrThrow(projectId, userId);
}
// Get order in new project
const existingTasks = await this.findAll(userId, { projectId: projectId ?? undefined });
const maxOrder = existingTasks.reduce((max, t) => Math.max(max, t.order ?? 0), -1);
const [updated] = await this.db
.update(tasks)
.set({
projectId,
order: maxOrder + 1,
updatedAt: new Date(),
})
.where(and(eq(tasks.id, id), eq(tasks.userId, userId)))
.returning();
return this.loadTaskLabels(updated);
}
async updateTaskLabels(taskId: string, userId: string, labelIds: string[]): Promise<void> {
await this.findByIdOrThrow(taskId, userId);
// Delete existing labels
await this.db.delete(taskLabels).where(eq(taskLabels.taskId, taskId));
// Insert new labels
if (labelIds.length > 0) {
await this.db.insert(taskLabels).values(
labelIds.map((labelId) => ({
taskId,
labelId,
}))
);
}
}
async getInboxTasks(userId: string): Promise<Task[]> {
return this.findAll(userId, { isCompleted: false });
}
async getTodayTasks(userId: string): Promise<Task[]> {
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
// Get tasks due today or overdue
const result = await this.db.query.tasks.findMany({
where: and(
eq(tasks.userId, userId),
eq(tasks.isCompleted, false),
or(
and(gte(tasks.dueDate, today), lte(tasks.dueDate, tomorrow)),
lte(tasks.dueDate, today) // Overdue
)
),
orderBy: [asc(tasks.dueDate), asc(tasks.order)],
});
return Promise.all(result.map((task) => this.loadTaskLabels(task)));
}
async getUpcomingTasks(userId: string, days: number = 7): Promise<Task[]> {
const today = new Date();
today.setHours(0, 0, 0, 0);
const endDate = new Date(today);
endDate.setDate(endDate.getDate() + days);
const result = await this.db.query.tasks.findMany({
where: and(
eq(tasks.userId, userId),
eq(tasks.isCompleted, false),
gte(tasks.dueDate, today),
lte(tasks.dueDate, endDate)
),
orderBy: [asc(tasks.dueDate), asc(tasks.order)],
});
return Promise.all(result.map((task) => this.loadTaskLabels(task)));
}
async getCompletedTasks(userId: string, limit: number = 50): Promise<Task[]> {
const result = await this.db.query.tasks.findMany({
where: and(eq(tasks.userId, userId), eq(tasks.isCompleted, true)),
orderBy: [desc(tasks.completedAt)],
limit,
});
return Promise.all(result.map((task) => this.loadTaskLabels(task)));
}
async reorder(userId: string, taskIds: string[], projectId?: string | null): Promise<Task[]> {
// Update order for each task
const updates = taskIds.map((id, index) =>
this.db
.update(tasks)
.set({ order: index, updatedAt: new Date() })
.where(and(eq(tasks.id, id), eq(tasks.userId, userId)))
);
await Promise.all(updates);
return this.findAll(userId, { projectId: projectId ?? undefined });
}
private async loadTaskLabels(
task: Task
): Promise<Task & { labels: (typeof labels.$inferSelect)[] }> {
const taskLabelRows = await this.db.query.taskLabels.findMany({
where: eq(taskLabels.taskId, task.id),
});
if (taskLabelRows.length === 0) {
return { ...task, labels: [] };
}
const labelIds = taskLabelRows.map((tl) => tl.labelId);
const taskLabelsData = await this.db.query.labels.findMany({
where: or(...labelIds.map((id) => eq(labels.id, id))),
});
return { ...task, labels: taskLabelsData };
}
}

View file

@ -0,0 +1,28 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -0,0 +1,50 @@
{
"name": "@todo/web",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "eslint .",
"format": "prettier --write .",
"type-check": "echo 'Skipping type-check for now'"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.1.7",
"@types/node": "^20.0.0",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.1.7",
"tslib": "^2.4.1",
"typescript": "^5.0.0",
"vite": "^6.0.0"
},
"dependencies": {
"@todo/shared": "workspace:*",
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-branding": "workspace:*",
"@manacore/shared-feedback-service": "workspace:*",
"@manacore/shared-feedback-ui": "workspace:*",
"@manacore/shared-i18n": "workspace:*",
"@manacore/shared-icons": "workspace:*",
"@manacore/shared-profile-ui": "workspace:*",
"@manacore/shared-subscription-ui": "workspace:*",
"@manacore/shared-tailwind": "workspace:*",
"@manacore/shared-theme": "workspace:*",
"@manacore/shared-theme-ui": "workspace:*",
"@manacore/shared-ui": "workspace:*",
"date-fns": "^4.1.0",
"svelte-dnd-action": "^0.9.68",
"svelte-i18n": "^4.0.1"
},
"type": "module"
}

View file

@ -0,0 +1,135 @@
@import 'tailwindcss';
@import '@manacore/shared-tailwind/themes.css';
/* Scan shared packages for Tailwind classes */
@source "../../../packages/shared/src";
@source "../../../../../packages/shared-ui/src";
@source "../../../../../packages/shared-theme-ui/src";
:root {
/* Todo App - Purple/Violet Theme */
--color-primary: #8b5cf6;
--color-primary-hover: #7c3aed;
--color-primary-light: #a78bfa;
--color-primary-dark: #6d28d9;
--color-secondary: #f3e8ff;
--color-secondary-hover: #e9d5ff;
--color-accent: #c084fc;
--color-accent-hover: #a855f7;
/* Task priorities */
--color-priority-low: #22c55e;
--color-priority-medium: #eab308;
--color-priority-high: #f97316;
--color-priority-urgent: #ef4444;
/* Status colors */
--color-pending: #6b7280;
--color-in-progress: #3b82f6;
--color-completed: #22c55e;
--color-cancelled: #ef4444;
}
/* Dark mode overrides */
:root.dark {
--color-secondary: #1e1b4b;
--color-secondary-hover: #2e1065;
}
/* Task item transitions */
.task-item {
transition:
transform 0.15s ease,
box-shadow 0.15s ease;
}
.task-item:hover {
transform: translateY(-1px);
}
/* Checkbox animations */
.task-checkbox {
transition:
background-color 0.2s ease,
border-color 0.2s ease,
transform 0.1s ease;
}
.task-checkbox:active {
transform: scale(0.9);
}
/* Subtask list */
.subtask-list {
list-style: none;
padding-left: 0;
}
/* Due date styling */
.due-today {
color: var(--color-priority-high);
}
.due-overdue {
color: var(--color-priority-urgent);
}
/* Priority indicators */
.priority-indicator {
width: 4px;
border-radius: 2px;
}
.priority-low {
background-color: var(--color-priority-low);
}
.priority-medium {
background-color: var(--color-priority-medium);
}
.priority-high {
background-color: var(--color-priority-high);
}
.priority-urgent {
background-color: var(--color-priority-urgent);
}
/* Drag and drop */
.dragging {
opacity: 0.5;
}
.drop-target {
border: 2px dashed var(--color-primary);
background-color: var(--color-secondary);
}
/* Quick add input */
.quick-add-input {
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.quick-add-input:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.2);
}
/* Project sidebar */
.project-item {
transition: background-color 0.15s ease;
}
.project-item:hover {
background-color: var(--color-secondary);
}
.project-item.active {
background-color: var(--color-secondary);
border-left: 3px solid var(--color-primary);
}

View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,90 @@
import { browser } from '$app/environment';
import { PUBLIC_BACKEND_URL } from '$env/static/public';
interface ApiOptions {
method?: string;
body?: unknown;
headers?: Record<string, string>;
}
interface ApiError {
message: string;
statusCode: number;
}
class ApiClient {
private baseUrl: string;
private accessToken: string | null = null;
constructor() {
this.baseUrl = PUBLIC_BACKEND_URL || 'http://localhost:3018';
}
setAccessToken(token: string | null) {
this.accessToken = token;
}
getAccessToken(): string | null {
return this.accessToken;
}
async fetch<T>(endpoint: string, options: ApiOptions = {}): Promise<T> {
const { method = 'GET', body, headers = {} } = options;
const requestHeaders: Record<string, string> = {
'Content-Type': 'application/json',
...headers,
};
if (this.accessToken) {
requestHeaders['Authorization'] = `Bearer ${this.accessToken}`;
}
const response = await fetch(`${this.baseUrl}${endpoint}`, {
method,
headers: requestHeaders,
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
let errorMessage = 'An error occurred';
try {
const errorData = (await response.json()) as ApiError;
errorMessage = errorData.message || errorMessage;
} catch {
errorMessage = response.statusText || errorMessage;
}
throw new Error(errorMessage);
}
// Handle 204 No Content
if (response.status === 204) {
return {} as T;
}
return response.json() as Promise<T>;
}
// Convenience methods
get<T>(endpoint: string, headers?: Record<string, string>): Promise<T> {
return this.fetch<T>(endpoint, { method: 'GET', headers });
}
post<T>(endpoint: string, body?: unknown, headers?: Record<string, string>): Promise<T> {
return this.fetch<T>(endpoint, { method: 'POST', body, headers });
}
put<T>(endpoint: string, body?: unknown, headers?: Record<string, string>): Promise<T> {
return this.fetch<T>(endpoint, { method: 'PUT', body, headers });
}
patch<T>(endpoint: string, body?: unknown, headers?: Record<string, string>): Promise<T> {
return this.fetch<T>(endpoint, { method: 'PATCH', body, headers });
}
delete<T>(endpoint: string, headers?: Record<string, string>): Promise<T> {
return this.fetch<T>(endpoint, { method: 'DELETE', headers });
}
}
export const apiClient = new ApiClient();

View file

@ -0,0 +1,5 @@
export { apiClient } from './client';
export * from './projects';
export * from './tasks';
export * from './labels';
export * from './reminders';

View file

@ -0,0 +1,39 @@
import { apiClient } from './client';
import type { Label } from '@todo/shared';
interface CreateLabelDto {
name: string;
color?: string;
}
interface UpdateLabelDto {
name?: string;
color?: string;
}
interface LabelsResponse {
labels: Label[];
}
interface LabelResponse {
label: Label;
}
export async function getLabels(): Promise<Label[]> {
const response = await apiClient.get<LabelsResponse>('/api/v1/labels');
return response.labels;
}
export async function createLabel(data: CreateLabelDto): Promise<Label> {
const response = await apiClient.post<LabelResponse>('/api/v1/labels', data);
return response.label;
}
export async function updateLabel(id: string, data: UpdateLabelDto): Promise<Label> {
const response = await apiClient.put<LabelResponse>(`/api/v1/labels/${id}`, data);
return response.label;
}
export async function deleteLabel(id: string): Promise<void> {
await apiClient.delete(`/api/v1/labels/${id}`);
}

View file

@ -0,0 +1,62 @@
import { apiClient } from './client';
import type { Project } from '@todo/shared';
interface CreateProjectDto {
name: string;
description?: string;
color?: string;
icon?: string;
}
interface UpdateProjectDto {
name?: string;
description?: string;
color?: string;
icon?: string;
isArchived?: boolean;
}
interface ReorderProjectsDto {
projectIds: string[];
}
interface ProjectsResponse {
projects: Project[];
}
interface ProjectResponse {
project: Project;
}
export async function getProjects(): Promise<Project[]> {
const response = await apiClient.get<ProjectsResponse>('/api/v1/projects');
return response.projects;
}
export async function getProject(id: string): Promise<Project> {
const response = await apiClient.get<ProjectResponse>(`/api/v1/projects/${id}`);
return response.project;
}
export async function createProject(data: CreateProjectDto): Promise<Project> {
const response = await apiClient.post<ProjectResponse>('/api/v1/projects', data);
return response.project;
}
export async function updateProject(id: string, data: UpdateProjectDto): Promise<Project> {
const response = await apiClient.put<ProjectResponse>(`/api/v1/projects/${id}`, data);
return response.project;
}
export async function deleteProject(id: string): Promise<void> {
await apiClient.delete(`/api/v1/projects/${id}`);
}
export async function archiveProject(id: string): Promise<Project> {
const response = await apiClient.post<ProjectResponse>(`/api/v1/projects/${id}/archive`);
return response.project;
}
export async function reorderProjects(projectIds: string[]): Promise<void> {
await apiClient.put('/api/v1/projects/reorder', { projectIds } as ReorderProjectsDto);
}

View file

@ -0,0 +1,32 @@
import { apiClient } from './client';
import type { Reminder, ReminderType } from '@todo/shared';
interface CreateReminderDto {
minutesBefore: number;
type?: ReminderType;
}
interface RemindersResponse {
reminders: Reminder[];
}
interface ReminderResponse {
reminder: Reminder;
}
export async function getReminders(taskId: string): Promise<Reminder[]> {
const response = await apiClient.get<RemindersResponse>(`/api/v1/tasks/${taskId}/reminders`);
return response.reminders;
}
export async function createReminder(taskId: string, data: CreateReminderDto): Promise<Reminder> {
const response = await apiClient.post<ReminderResponse>(
`/api/v1/tasks/${taskId}/reminders`,
data
);
return response.reminder;
}
export async function deleteReminder(id: string): Promise<void> {
await apiClient.delete(`/api/v1/reminders/${id}`);
}

View file

@ -0,0 +1,123 @@
import { apiClient } from './client';
import type { Task, TaskPriority, TaskStatus, Subtask, Label } from '@todo/shared';
interface CreateTaskDto {
title: string;
description?: string;
projectId?: string;
dueDate?: string;
priority?: TaskPriority;
labelIds?: string[];
subtasks?: Subtask[];
recurrenceRule?: string;
}
interface UpdateTaskDto {
title?: string;
description?: string;
projectId?: string | null;
dueDate?: string | null;
priority?: TaskPriority;
status?: TaskStatus;
subtasks?: Subtask[];
recurrenceRule?: string | null;
}
interface TaskQuery {
projectId?: string;
labelId?: string;
priority?: TaskPriority;
status?: TaskStatus;
dueBefore?: string;
dueAfter?: string;
isCompleted?: boolean;
search?: string;
}
interface TasksResponse {
tasks: Task[];
}
interface TaskResponse {
task: Task;
}
function buildQueryString(query: TaskQuery): string {
const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
params.append(key, String(value));
}
});
const queryString = params.toString();
return queryString ? `?${queryString}` : '';
}
export async function getTasks(query: TaskQuery = {}): Promise<Task[]> {
const queryString = buildQueryString(query);
const response = await apiClient.get<TasksResponse>(`/api/v1/tasks${queryString}`);
return response.tasks;
}
export async function getTask(id: string): Promise<Task> {
const response = await apiClient.get<TaskResponse>(`/api/v1/tasks/${id}`);
return response.task;
}
export async function createTask(data: CreateTaskDto): Promise<Task> {
const response = await apiClient.post<TaskResponse>('/api/v1/tasks', data);
return response.task;
}
export async function updateTask(id: string, data: UpdateTaskDto): Promise<Task> {
const response = await apiClient.put<TaskResponse>(`/api/v1/tasks/${id}`, data);
return response.task;
}
export async function deleteTask(id: string): Promise<void> {
await apiClient.delete(`/api/v1/tasks/${id}`);
}
export async function completeTask(id: string): Promise<Task> {
const response = await apiClient.post<TaskResponse>(`/api/v1/tasks/${id}/complete`);
return response.task;
}
export async function uncompleteTask(id: string): Promise<Task> {
const response = await apiClient.post<TaskResponse>(`/api/v1/tasks/${id}/uncomplete`);
return response.task;
}
export async function moveTask(id: string, projectId: string | null): Promise<Task> {
const response = await apiClient.post<TaskResponse>(`/api/v1/tasks/${id}/move`, { projectId });
return response.task;
}
export async function updateTaskLabels(id: string, labelIds: string[]): Promise<Task> {
const response = await apiClient.put<TaskResponse>(`/api/v1/tasks/${id}/labels`, { labelIds });
return response.task;
}
export async function updateSubtasks(id: string, subtasks: Subtask[]): Promise<Task> {
const response = await apiClient.put<TaskResponse>(`/api/v1/tasks/${id}/subtasks`, { subtasks });
return response.task;
}
export async function getInboxTasks(): Promise<Task[]> {
const response = await apiClient.get<TasksResponse>('/api/v1/tasks/inbox');
return response.tasks;
}
export async function getTodayTasks(): Promise<Task[]> {
const response = await apiClient.get<TasksResponse>('/api/v1/tasks/today');
return response.tasks;
}
export async function getUpcomingTasks(): Promise<Task[]> {
const response = await apiClient.get<TasksResponse>('/api/v1/tasks/upcoming');
return response.tasks;
}
export async function reorderTasks(taskIds: string[]): Promise<void> {
await apiClient.put('/api/v1/tasks/reorder', { taskIds });
}

View file

@ -0,0 +1,32 @@
<script lang="ts">
import { AppSlider, type AppItem } from '@manacore/shared-ui';
import { MANA_APPS, APP_STATUS_LABELS, APP_SLIDER_LABELS } from '@manacore/shared-branding';
// Convert MANA_APPS to AppItem format (German)
const apps: AppItem[] = MANA_APPS.map((app) => ({
name: app.name,
description: app.description.de,
longDescription: app.longDescription.de,
icon: app.icon,
color: app.color,
comingSoon: app.comingSoon,
status: app.status,
}));
const statusLabels = APP_STATUS_LABELS.de;
const labels = APP_SLIDER_LABELS.de;
function handleAppClick(app: AppItem, index: number) {
console.log('Opening app:', app.name);
}
</script>
<AppSlider
{apps}
title={labels.title}
isDark={false}
{statusLabels}
comingSoonLabel={labels.comingSoon}
openAppLabel={labels.openApp}
onAppClick={handleAppClick}
/>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { locale } from 'svelte-i18n';
import { PillDropdown } from '@manacore/shared-ui';
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
const supportedLocales = ['de', 'en'];
let currentLocale = $derived($locale || 'de');
function handleLocaleChange(newLocale: string) {
locale.set(newLocale);
}
let languageItems = $derived(
getLanguageDropdownItems(supportedLocales, currentLocale, handleLocaleChange)
);
let currentLabel = $derived(getCurrentLanguageLabel(currentLocale));
</script>
<PillDropdown items={languageItems} label={currentLabel} direction="down" />

View file

@ -0,0 +1,68 @@
<script lang="ts">
import { tasksStore } from '$lib/stores/tasks.svelte';
import { viewStore } from '$lib/stores/view.svelte';
let inputValue = $state('');
let isLoading = $state(false);
let inputRef: HTMLInputElement;
async function handleSubmit(event: Event) {
event.preventDefault();
const title = inputValue.trim();
if (!title || isLoading) return;
isLoading = true;
try {
// Create task with current project if in project view
const projectId =
viewStore.currentView === 'project' ? viewStore.currentProjectId : undefined;
await tasksStore.createTask({
title,
projectId: projectId || undefined,
});
inputValue = '';
inputRef?.focus();
} catch (error) {
console.error('Failed to create task:', error);
} finally {
isLoading = false;
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
inputValue = '';
inputRef?.blur();
}
}
</script>
<form onsubmit={handleSubmit} class="mb-6">
<div class="relative">
<div class="absolute left-4 top-1/2 -translate-y-1/2 text-muted-foreground">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
</div>
<input
bind:this={inputRef}
bind:value={inputValue}
onkeydown={handleKeydown}
type="text"
placeholder="Neue Aufgabe hinzufügen..."
class="quick-add-input w-full pl-12 pr-4 py-3 rounded-lg border border-border bg-card text-foreground placeholder:text-muted-foreground focus:outline-none"
disabled={isLoading}
/>
{#if isLoading}
<div class="absolute right-4 top-1/2 -translate-y-1/2">
<div
class="animate-spin h-5 w-5 border-2 border-primary border-r-transparent rounded-full"
></div>
</div>
{/if}
</div>
</form>

View file

@ -0,0 +1,181 @@
<script lang="ts">
import type { Task } from '@todo/shared';
import { format, isToday, isPast, isTomorrow } from 'date-fns';
import { de } from 'date-fns/locale';
import { projectsStore } from '$lib/stores/projects.svelte';
interface Props {
task: Task;
showCompleted?: boolean;
onToggleComplete: () => void;
onDelete: () => void;
}
let { task, showCompleted = false, onToggleComplete, onDelete }: Props = $props();
// Priority colors
const priorityColors: Record<string, string> = {
low: 'bg-green-500',
medium: 'bg-yellow-500',
high: 'bg-orange-500',
urgent: 'bg-red-500',
};
// Format due date
let dueDateText = $derived(() => {
if (!task.dueDate) return null;
const date = new Date(task.dueDate);
if (isToday(date)) return 'Heute';
if (isTomorrow(date)) return 'Morgen';
return format(date, 'dd. MMM', { locale: de });
});
// Check if overdue
let isOverdue = $derived(() => {
if (!task.dueDate || task.isCompleted) return false;
const date = new Date(task.dueDate);
return isPast(date) && !isToday(date);
});
// Get project color
let projectColor = $derived(() => {
if (!task.projectId) return null;
return projectsStore.getColor(task.projectId);
});
// Subtasks progress
let subtaskProgress = $derived(() => {
if (!task.subtasks || task.subtasks.length === 0) return null;
const completed = task.subtasks.filter((s) => s.isCompleted).length;
return `${completed}/${task.subtasks.length}`;
});
</script>
<div
class="task-item group flex items-start gap-3 p-3 rounded-lg border border-border bg-card hover:bg-accent/5 transition-colors"
class:opacity-60={task.isCompleted}
>
<!-- Priority indicator -->
<div class="priority-indicator {priorityColors[task.priority]} h-full min-h-[40px]"></div>
<!-- Checkbox -->
<button
class="task-checkbox flex-shrink-0 w-5 h-5 rounded-full border-2 border-muted-foreground hover:border-primary flex items-center justify-center mt-0.5"
class:bg-primary={task.isCompleted}
class:border-primary={task.isCompleted}
onclick={onToggleComplete}
>
{#if task.isCompleted}
<svg class="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
{/if}
</button>
<!-- Content -->
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between gap-2">
<h3
class="text-sm font-medium text-foreground truncate"
class:line-through={task.isCompleted}
>
{task.title}
</h3>
<!-- Delete button (hidden by default, shown on hover) -->
<button
class="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-red-500 transition-opacity"
onclick={onDelete}
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
{#if task.description}
<p class="text-xs text-muted-foreground mt-1 line-clamp-2">
{task.description}
</p>
{/if}
<!-- Meta info -->
<div class="flex items-center gap-3 mt-2 flex-wrap">
{#if dueDateText()}
<span
class="text-xs flex items-center gap-1"
class:text-red-500={isOverdue()}
class:text-orange-500={isToday(new Date(task.dueDate || 0))}
class:text-muted-foreground={!isOverdue() && !isToday(new Date(task.dueDate || 0))}
>
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
{dueDateText()}
</span>
{/if}
{#if subtaskProgress()}
<span class="text-xs text-muted-foreground flex items-center gap-1">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
/>
</svg>
{subtaskProgress()}
</span>
{/if}
{#if task.labels && task.labels.length > 0}
<div class="flex items-center gap-1">
{#each task.labels.slice(0, 3) as label}
<span
class="text-xs px-1.5 py-0.5 rounded"
style="background-color: {label.color}20; color: {label.color}"
>
{label.name}
</span>
{/each}
{#if task.labels.length > 3}
<span class="text-xs text-muted-foreground">+{task.labels.length - 3}</span>
{/if}
</div>
{/if}
{#if task.recurrenceRule}
<span class="text-xs text-muted-foreground flex items-center gap-1">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
Wiederkehrend
</span>
{/if}
</div>
</div>
<!-- Project color indicator -->
{#if projectColor()}
<div
class="w-2 h-2 rounded-full flex-shrink-0"
style="background-color: {projectColor()}"
></div>
{/if}
</div>

View file

@ -0,0 +1,35 @@
<script lang="ts">
import type { Task } from '@todo/shared';
import TaskItem from './TaskItem.svelte';
import { tasksStore } from '$lib/stores/tasks.svelte';
interface Props {
tasks: Task[];
showCompleted?: boolean;
}
let { tasks, showCompleted = false }: Props = $props();
async function handleToggleComplete(task: Task) {
if (task.isCompleted) {
await tasksStore.uncompleteTask(task.id);
} else {
await tasksStore.completeTask(task.id);
}
}
async function handleDelete(taskId: string) {
await tasksStore.deleteTask(taskId);
}
</script>
<div class="task-list space-y-2">
{#each tasks as task (task.id)}
<TaskItem
{task}
{showCompleted}
onToggleComplete={() => handleToggleComplete(task)}
onDelete={() => handleDelete(task.id)}
/>
{/each}
</div>

View file

@ -0,0 +1,201 @@
/**
* Auth Store - Manages authentication state using Svelte 5 runes
* Uses Mana Core Auth
*/
import { browser } from '$app/environment';
import { initializeWebAuth, type UserData } from '@manacore/shared-auth';
import { apiClient } from '$lib/api/client';
// Initialize Mana Core Auth only on the client side
const MANA_AUTH_URL = 'http://localhost:3001';
// Lazy initialization to avoid SSR issues with localStorage
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
function getAuthService() {
if (!browser) return null;
if (!_authService) {
const auth = initializeWebAuth({ baseUrl: MANA_AUTH_URL });
_authService = auth.authService;
_tokenManager = auth.tokenManager;
}
return _authService;
}
// State
let user = $state<UserData | null>(null);
let loading = $state(true);
let initialized = $state(false);
export const authStore = {
// Getters
get user() {
return user;
},
get loading() {
return loading;
},
get isAuthenticated() {
return !!user;
},
get initialized() {
return initialized;
},
/**
* Initialize auth state from stored tokens
*/
async initialize() {
if (initialized) return;
const authService = getAuthService();
if (!authService) {
initialized = true;
loading = false;
return;
}
loading = true;
try {
const authenticated = await authService.isAuthenticated();
if (authenticated) {
const userData = await authService.getUserFromToken();
user = userData;
// Set token for API client
const token = await authService.getAppToken();
if (token) {
apiClient.setAccessToken(token);
}
}
initialized = true;
} catch (error) {
console.error('Failed to initialize auth:', error);
user = null;
} finally {
loading = false;
}
},
/**
* Sign in with email and password
*/
async signIn(email: string, password: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try {
const result = await authService.signIn(email, password);
if (!result.success) {
return { success: false, error: result.error || 'Login failed' };
}
// Get user data from token
const userData = await authService.getUserFromToken();
user = userData;
// Set token for API client
const token = await authService.getAppToken();
if (token) {
apiClient.setAccessToken(token);
}
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
}
},
/**
* Sign up with email and password
*/
async signUp(email: string, password: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server', needsVerification: false };
}
try {
const result = await authService.signUp(email, password);
if (!result.success) {
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
}
// Mana Core Auth requires separate login after signup
if (result.needsVerification) {
return { success: true, needsVerification: true };
}
// Auto sign in after successful signup
const signInResult = await this.signIn(email, password);
return { ...signInResult, needsVerification: false };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage, needsVerification: false };
}
},
/**
* Sign out
*/
async signOut() {
const authService = getAuthService();
if (!authService) {
user = null;
apiClient.setAccessToken(null);
return;
}
try {
await authService.signOut();
user = null;
apiClient.setAccessToken(null);
} catch (error) {
console.error('Sign out error:', error);
// Clear user even if sign out fails
user = null;
apiClient.setAccessToken(null);
}
},
/**
* Send password reset email
*/
async resetPassword(email: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try {
const result = await authService.forgotPassword(email);
if (!result.success) {
return { success: false, error: result.error || 'Password reset failed' };
}
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
}
},
/**
* Get access token for API calls
*/
async getAccessToken() {
const authService = getAuthService();
if (!authService) {
return null;
}
return await authService.getAppToken();
},
};

View file

@ -0,0 +1,6 @@
export { authStore } from './auth.svelte';
export { projectsStore } from './projects.svelte';
export { tasksStore } from './tasks.svelte';
export { labelsStore } from './labels.svelte';
export { viewStore } from './view.svelte';
export type { ViewType, SortBy, SortOrder } from './view.svelte';

View file

@ -0,0 +1,114 @@
/**
* Labels Store - Manages label state using Svelte 5 runes
*/
import type { Label } from '@todo/shared';
import * as labelsApi from '$lib/api/labels';
// State
let labels = $state<Label[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
export const labelsStore = {
// Getters
get labels() {
return labels;
},
get loading() {
return loading;
},
get error() {
return error;
},
/**
* Fetch all labels from API
*/
async fetchLabels() {
loading = true;
error = null;
try {
labels = await labelsApi.getLabels();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch labels';
console.error('Failed to fetch labels:', e);
} finally {
loading = false;
}
},
/**
* Get label by ID
*/
getById(id: string): Label | undefined {
return labels.find((l) => l.id === id);
},
/**
* Get label color by ID
*/
getColor(labelId: string): string {
const label = labels.find((l) => l.id === labelId);
return label?.color || '#6b7280';
},
/**
* Create a new label
*/
async createLabel(data: { name: string; color?: string }) {
loading = true;
error = null;
try {
const newLabel = await labelsApi.createLabel(data);
labels = [...labels, newLabel];
return newLabel;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create label';
console.error('Failed to create label:', e);
throw e;
} finally {
loading = false;
}
},
/**
* Update an existing label
*/
async updateLabel(id: string, data: { name?: string; color?: string }) {
error = null;
try {
const updatedLabel = await labelsApi.updateLabel(id, data);
labels = labels.map((l) => (l.id === id ? updatedLabel : l));
return updatedLabel;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update label';
console.error('Failed to update label:', e);
throw e;
}
},
/**
* Delete a label
*/
async deleteLabel(id: string) {
error = null;
try {
await labelsApi.deleteLabel(id);
labels = labels.filter((l) => l.id !== id);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete label';
console.error('Failed to delete label:', e);
throw e;
}
},
/**
* Clear all state (for logout)
*/
clear() {
labels = [];
loading = false;
error = null;
},
};

View file

@ -0,0 +1,173 @@
/**
* Projects Store - Manages project state using Svelte 5 runes
*/
import type { Project } from '@todo/shared';
import * as projectsApi from '$lib/api/projects';
// State
let projects = $state<Project[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
export const projectsStore = {
// Getters
get projects() {
return projects;
},
get loading() {
return loading;
},
get error() {
return error;
},
/**
* Get inbox project (default project)
*/
get inboxProject() {
return projects.find((p) => p.isDefault);
},
/**
* Get non-archived projects sorted by order
*/
get activeProjects() {
return projects.filter((p) => !p.isArchived).sort((a, b) => a.order - b.order);
},
/**
* Get archived projects
*/
get archivedProjects() {
return projects.filter((p) => p.isArchived);
},
/**
* Fetch all projects from API
*/
async fetchProjects() {
loading = true;
error = null;
try {
projects = await projectsApi.getProjects();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch projects';
console.error('Failed to fetch projects:', e);
} finally {
loading = false;
}
},
/**
* Get project by ID
*/
getById(id: string): Project | undefined {
return projects.find((p) => p.id === id);
},
/**
* Get project color by ID
*/
getColor(projectId: string): string {
const project = projects.find((p) => p.id === projectId);
return project?.color || '#6b7280';
},
/**
* Create a new project
*/
async createProject(data: { name: string; description?: string; color?: string; icon?: string }) {
loading = true;
error = null;
try {
const newProject = await projectsApi.createProject(data);
projects = [...projects, newProject];
return newProject;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create project';
console.error('Failed to create project:', e);
throw e;
} finally {
loading = false;
}
},
/**
* Update an existing project
*/
async updateProject(
id: string,
data: { name?: string; description?: string; color?: string; icon?: string }
) {
error = null;
try {
const updatedProject = await projectsApi.updateProject(id, data);
projects = projects.map((p) => (p.id === id ? updatedProject : p));
return updatedProject;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update project';
console.error('Failed to update project:', e);
throw e;
}
},
/**
* Delete a project
*/
async deleteProject(id: string) {
error = null;
try {
await projectsApi.deleteProject(id);
projects = projects.filter((p) => p.id !== id);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete project';
console.error('Failed to delete project:', e);
throw e;
}
},
/**
* Archive a project
*/
async archiveProject(id: string) {
error = null;
try {
const archivedProject = await projectsApi.archiveProject(id);
projects = projects.map((p) => (p.id === id ? archivedProject : p));
return archivedProject;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to archive project';
console.error('Failed to archive project:', e);
throw e;
}
},
/**
* Reorder projects
*/
async reorderProjects(projectIds: string[]) {
error = null;
try {
await projectsApi.reorderProjects(projectIds);
// Update local order
projects = projects.map((p) => {
const newOrder = projectIds.indexOf(p.id);
return newOrder !== -1 ? { ...p, order: newOrder } : p;
});
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to reorder projects';
console.error('Failed to reorder projects:', e);
throw e;
}
},
/**
* Clear all state (for logout)
*/
clear() {
projects = [];
loading = false;
error = null;
},
};

View file

@ -0,0 +1,341 @@
/**
* Tasks Store - Manages task state using Svelte 5 runes
*/
import type { Task, TaskPriority, TaskStatus, Subtask } from '@todo/shared';
import * as tasksApi from '$lib/api/tasks';
import { isToday, isPast, isFuture, startOfDay, addDays } from 'date-fns';
// State
let tasks = $state<Task[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
export const tasksStore = {
// Getters
get tasks() {
return tasks;
},
get loading() {
return loading;
},
get error() {
return error;
},
/**
* Get incomplete tasks
*/
get incompleteTasks() {
return tasks.filter((t) => !t.isCompleted);
},
/**
* Get completed tasks
*/
get completedTasks() {
return tasks.filter((t) => t.isCompleted);
},
/**
* Fetch tasks with optional filters
*/
async fetchTasks(
query: {
projectId?: string;
labelId?: string;
priority?: TaskPriority;
status?: TaskStatus;
dueBefore?: string;
dueAfter?: string;
isCompleted?: boolean;
search?: string;
} = {}
) {
loading = true;
error = null;
try {
tasks = await tasksApi.getTasks(query);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch tasks';
console.error('Failed to fetch tasks:', e);
} finally {
loading = false;
}
},
/**
* Fetch inbox tasks (tasks without project)
*/
async fetchInboxTasks() {
loading = true;
error = null;
try {
tasks = await tasksApi.getInboxTasks();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch inbox tasks';
console.error('Failed to fetch inbox tasks:', e);
} finally {
loading = false;
}
},
/**
* Fetch today's tasks
*/
async fetchTodayTasks() {
loading = true;
error = null;
try {
tasks = await tasksApi.getTodayTasks();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch today tasks';
console.error('Failed to fetch today tasks:', e);
} finally {
loading = false;
}
},
/**
* Fetch upcoming tasks
*/
async fetchUpcomingTasks() {
loading = true;
error = null;
try {
tasks = await tasksApi.getUpcomingTasks();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch upcoming tasks';
console.error('Failed to fetch upcoming tasks:', e);
} finally {
loading = false;
}
},
/**
* Get tasks for a specific project
*/
getTasksByProject(projectId: string | null): Task[] {
if (projectId === null) {
return tasks.filter((t) => !t.projectId);
}
return tasks.filter((t) => t.projectId === projectId);
},
/**
* Get tasks with a specific label
*/
getTasksByLabel(labelId: string): Task[] {
return tasks.filter((t) => t.labels?.some((l) => l.id === labelId));
},
/**
* Get overdue tasks
*/
get overdueTasks(): Task[] {
return tasks.filter((t) => {
if (!t.dueDate || t.isCompleted) return false;
const dueDate = new Date(t.dueDate);
return isPast(startOfDay(dueDate)) && !isToday(dueDate);
});
},
/**
* Get tasks due today
*/
get todayTasks(): Task[] {
return tasks.filter((t) => {
if (!t.dueDate || t.isCompleted) return false;
return isToday(new Date(t.dueDate));
});
},
/**
* Get tasks for next 7 days
*/
get upcomingTasks(): Task[] {
const today = startOfDay(new Date());
const weekFromNow = addDays(today, 7);
return tasks.filter((t) => {
if (!t.dueDate || t.isCompleted) return false;
const dueDate = new Date(t.dueDate);
return isFuture(dueDate) && dueDate <= weekFromNow;
});
},
/**
* Create a new task
*/
async createTask(data: {
title: string;
description?: string;
projectId?: string;
dueDate?: string;
priority?: TaskPriority;
labelIds?: string[];
subtasks?: Subtask[];
recurrenceRule?: string;
}) {
error = null;
try {
const newTask = await tasksApi.createTask(data);
tasks = [...tasks, newTask];
return newTask;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create task';
console.error('Failed to create task:', e);
throw e;
}
},
/**
* Update an existing task
*/
async updateTask(
id: string,
data: {
title?: string;
description?: string;
projectId?: string | null;
dueDate?: string | null;
priority?: TaskPriority;
status?: TaskStatus;
subtasks?: Subtask[];
recurrenceRule?: string | null;
}
) {
error = null;
try {
const updatedTask = await tasksApi.updateTask(id, data);
tasks = tasks.map((t) => (t.id === id ? updatedTask : t));
return updatedTask;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update task';
console.error('Failed to update task:', e);
throw e;
}
},
/**
* Delete a task
*/
async deleteTask(id: string) {
error = null;
try {
await tasksApi.deleteTask(id);
tasks = tasks.filter((t) => t.id !== id);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete task';
console.error('Failed to delete task:', e);
throw e;
}
},
/**
* Mark task as complete
*/
async completeTask(id: string) {
error = null;
try {
const completedTask = await tasksApi.completeTask(id);
tasks = tasks.map((t) => (t.id === id ? completedTask : t));
return completedTask;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to complete task';
console.error('Failed to complete task:', e);
throw e;
}
},
/**
* Mark task as incomplete
*/
async uncompleteTask(id: string) {
error = null;
try {
const uncompletedTask = await tasksApi.uncompleteTask(id);
tasks = tasks.map((t) => (t.id === id ? uncompletedTask : t));
return uncompletedTask;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to uncomplete task';
console.error('Failed to uncomplete task:', e);
throw e;
}
},
/**
* Move task to a different project
*/
async moveTask(id: string, projectId: string | null) {
error = null;
try {
const movedTask = await tasksApi.moveTask(id, projectId);
tasks = tasks.map((t) => (t.id === id ? movedTask : t));
return movedTask;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to move task';
console.error('Failed to move task:', e);
throw e;
}
},
/**
* Update task labels
*/
async updateLabels(id: string, labelIds: string[]) {
error = null;
try {
const updatedTask = await tasksApi.updateTaskLabels(id, labelIds);
tasks = tasks.map((t) => (t.id === id ? updatedTask : t));
return updatedTask;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update labels';
console.error('Failed to update labels:', e);
throw e;
}
},
/**
* Update subtasks
*/
async updateSubtasks(id: string, subtasks: Subtask[]) {
error = null;
try {
const updatedTask = await tasksApi.updateSubtasks(id, subtasks);
tasks = tasks.map((t) => (t.id === id ? updatedTask : t));
return updatedTask;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update subtasks';
console.error('Failed to update subtasks:', e);
throw e;
}
},
/**
* Reorder tasks
*/
async reorderTasks(taskIds: string[]) {
error = null;
try {
await tasksApi.reorderTasks(taskIds);
// Update local order
tasks = tasks.map((t) => {
const newOrder = taskIds.indexOf(t.id);
return newOrder !== -1 ? { ...t, order: newOrder } : t;
});
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to reorder tasks';
console.error('Failed to reorder tasks:', e);
throw e;
}
},
/**
* Clear all state (for logout)
*/
clear() {
tasks = [];
loading = false;
error = null;
},
};

View file

@ -0,0 +1,7 @@
import { createThemeStore } from '@manacore/shared-theme';
// Create theme store with Todo's primary color (purple/violet)
export const theme = createThemeStore({
appId: 'todo',
defaultVariant: 'amethyst',
});

View file

@ -0,0 +1,160 @@
/**
* View Store - Manages current view state using Svelte 5 runes
*/
export type ViewType =
| 'inbox'
| 'today'
| 'upcoming'
| 'project'
| 'label'
| 'completed'
| 'search';
export type SortBy = 'dueDate' | 'priority' | 'title' | 'createdAt' | 'order';
export type SortOrder = 'asc' | 'desc';
// State
let currentView = $state<ViewType>('inbox');
let currentProjectId = $state<string | null>(null);
let currentLabelId = $state<string | null>(null);
let searchQuery = $state('');
let sortBy = $state<SortBy>('order');
let sortOrder = $state<SortOrder>('asc');
let showCompleted = $state(false);
export const viewStore = {
// Getters
get currentView() {
return currentView;
},
get currentProjectId() {
return currentProjectId;
},
get currentLabelId() {
return currentLabelId;
},
get searchQuery() {
return searchQuery;
},
get sortBy() {
return sortBy;
},
get sortOrder() {
return sortOrder;
},
get showCompleted() {
return showCompleted;
},
/**
* Set current view to inbox
*/
setInbox() {
currentView = 'inbox';
currentProjectId = null;
currentLabelId = null;
searchQuery = '';
},
/**
* Set current view to today
*/
setToday() {
currentView = 'today';
currentProjectId = null;
currentLabelId = null;
searchQuery = '';
},
/**
* Set current view to upcoming
*/
setUpcoming() {
currentView = 'upcoming';
currentProjectId = null;
currentLabelId = null;
searchQuery = '';
},
/**
* Set current view to a specific project
*/
setProject(projectId: string) {
currentView = 'project';
currentProjectId = projectId;
currentLabelId = null;
searchQuery = '';
},
/**
* Set current view to a specific label
*/
setLabel(labelId: string) {
currentView = 'label';
currentProjectId = null;
currentLabelId = labelId;
searchQuery = '';
},
/**
* Set current view to completed
*/
setCompleted() {
currentView = 'completed';
currentProjectId = null;
currentLabelId = null;
searchQuery = '';
},
/**
* Set current view to search
*/
setSearch(query: string) {
currentView = 'search';
currentProjectId = null;
currentLabelId = null;
searchQuery = query;
},
/**
* Update search query
*/
updateSearchQuery(query: string) {
searchQuery = query;
},
/**
* Set sort options
*/
setSort(by: SortBy, order: SortOrder = 'asc') {
sortBy = by;
sortOrder = order;
},
/**
* Toggle sort order
*/
toggleSortOrder() {
sortOrder = sortOrder === 'asc' ? 'desc' : 'asc';
},
/**
* Toggle show completed
*/
toggleShowCompleted() {
showCompleted = !showCompleted;
},
/**
* Reset to default state
*/
reset() {
currentView = 'inbox';
currentProjectId = null;
currentLabelId = null;
searchQuery = '';
sortBy = 'order';
sortOrder = 'asc';
showCompleted = false;
},
};

View file

@ -0,0 +1,5 @@
<script lang="ts">
let { children } = $props();
</script>
{@render children()}

View file

@ -0,0 +1,40 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { locale } from 'svelte-i18n';
import { ForgotPasswordPage } from '@manacore/shared-auth-ui';
import { getForgotPasswordTranslations } from '@manacore/shared-i18n';
import { TodoLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
import AppSlider from '$lib/components/AppSlider.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
// Get translations based on current locale
const translations = $derived(getForgotPasswordTranslations($locale || 'de'));
async function handleResetPassword(email: string) {
return authStore.resetPassword(email);
}
</script>
<svelte:head>
<title>{translations.title} | Todo</title>
</svelte:head>
<ForgotPasswordPage
appName="Todo"
logo={TodoLogo}
primaryColor="#8b5cf6"
onResetPassword={handleResetPassword}
{goto}
loginPath="/login"
lightBackground="#f3e8ff"
darkBackground="#1e1b4b"
{translations}
>
{#snippet headerControls()}
<LanguageSelector />
{/snippet}
{#snippet appSlider()}
<AppSlider />
{/snippet}
</ForgotPasswordPage>

View file

@ -0,0 +1,48 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { locale } from 'svelte-i18n';
import { LoginPage } from '@manacore/shared-auth-ui';
import { getLoginTranslations } from '@manacore/shared-i18n';
import { TodoLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
import AppSlider from '$lib/components/AppSlider.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
// Get redirect URL from query params
const redirectTo = $derived($page.url.searchParams.get('redirectTo') || '/');
// Get translations based on current locale
const translations = $derived(getLoginTranslations($locale || 'de'));
async function handleSignIn(email: string, password: string) {
return authStore.signIn(email, password);
}
</script>
<svelte:head>
<title>{translations.title} | Todo</title>
</svelte:head>
<LoginPage
appName="Todo"
logo={TodoLogo}
primaryColor="#8b5cf6"
onSignIn={handleSignIn}
{goto}
enableGoogle={false}
enableApple={false}
successRedirect={redirectTo}
registerPath="/register"
forgotPasswordPath="/forgot-password"
lightBackground="#f3e8ff"
darkBackground="#1e1b4b"
{translations}
>
{#snippet headerControls()}
<LanguageSelector />
{/snippet}
{#snippet appSlider()}
<AppSlider />
{/snippet}
</LoginPage>

View file

@ -0,0 +1,43 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { locale } from 'svelte-i18n';
import { RegisterPage } from '@manacore/shared-auth-ui';
import { getRegisterTranslations } from '@manacore/shared-i18n';
import { TodoLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
import AppSlider from '$lib/components/AppSlider.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
// Get translations based on current locale
const translations = $derived(getRegisterTranslations($locale || 'de'));
async function handleSignUp(email: string, password: string) {
return authStore.signUp(email, password);
}
</script>
<svelte:head>
<title>{translations.title} | Todo</title>
</svelte:head>
<RegisterPage
appName="Todo"
logo={TodoLogo}
primaryColor="#8b5cf6"
onSignUp={handleSignUp}
{goto}
enableGoogle={false}
enableApple={false}
successRedirect="/"
loginPath="/login"
lightBackground="#f3e8ff"
darkBackground="#1e1b4b"
{translations}
>
{#snippet headerControls()}
<LanguageSelector />
{/snippet}
{#snippet appSlider()}
<AppSlider />
{/snippet}
</RegisterPage>

View file

@ -0,0 +1,262 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { locale } from 'svelte-i18n';
import { PillNavigation } from '@manacore/shared-ui';
import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui';
import { authStore } from '$lib/stores/auth.svelte';
import { projectsStore } from '$lib/stores/projects.svelte';
import { labelsStore } from '$lib/stores/labels.svelte';
import { theme } from '$lib/stores/theme';
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { getPillAppItems } from '@manacore/shared-branding';
import '../app.css';
// App switcher items
const appItems = getPillAppItems('todo');
let { children } = $props();
let loading = $state(true);
let isSidebarMode = $state(false);
let isCollapsed = $state(false);
// Use theme store's isDark directly
let isDark = $derived(theme.isDark);
// Theme variant dropdown items
let themeVariantItems = $derived<PillDropdownItem[]>([
...theme.variants.map((variant) => ({
id: variant,
label: THEME_DEFINITIONS[variant].label,
icon: THEME_DEFINITIONS[variant].icon,
onClick: () => theme.setVariant(variant),
active: theme.variant === variant,
})),
{
id: 'all-themes',
label: 'Alle Themes',
icon: 'palette',
onClick: () => goto('/themes'),
active: false,
},
]);
// Current theme variant label
let currentThemeVariantLabel = $derived(THEME_DEFINITIONS[theme.variant].label);
// Language selector items
let currentLocale = $derived($locale || 'de');
function handleLocaleChange(newLocale: string) {
locale.set(newLocale);
}
let languageItems = $derived(
getLanguageDropdownItems(['de', 'en'], currentLocale, handleLocaleChange)
);
let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale));
// User email for user dropdown
let userEmail = $derived(authStore.user?.email || 'Menü');
// Check if current route is an auth route (no navigation needed)
let isAuthRoute = $derived(
$page.url.pathname.startsWith('/login') ||
$page.url.pathname.startsWith('/register') ||
$page.url.pathname.startsWith('/forgot-password')
);
// Navigation items for Todo
const navItems: PillNavItem[] = [
{ href: '/', label: 'Inbox', icon: 'inbox' },
{ href: '/today', label: 'Heute', icon: 'calendar-day' },
{ href: '/upcoming', label: 'Demnächst', icon: 'calendar' },
{ href: '/completed', label: 'Erledigt', icon: 'check-circle' },
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
];
// Navigation shortcuts (Ctrl+1-5)
const navRoutes = navItems.map((item) => item.href);
function handleKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
}
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey) {
const num = parseInt(event.key);
if (num >= 1 && num <= navRoutes.length) {
event.preventDefault();
const route = navRoutes[num - 1];
if (route) {
goto(route);
}
}
}
}
function handleModeChange(isSidebar: boolean) {
isSidebarMode = isSidebar;
if (typeof localStorage !== 'undefined') {
localStorage.setItem('todo-nav-sidebar', String(isSidebar));
}
}
function handleCollapsedChange(collapsed: boolean) {
isCollapsed = collapsed;
if (typeof localStorage !== 'undefined') {
localStorage.setItem('todo-nav-collapsed', String(collapsed));
}
}
function handleToggleTheme() {
theme.toggleMode();
}
function handleThemeModeChange(mode: 'light' | 'dark' | 'system') {
theme.setMode(mode);
}
async function handleLogout() {
await authStore.signOut();
projectsStore.clear();
labelsStore.clear();
goto('/login');
}
onMount(async () => {
// Initialize theme
theme.initialize();
// Initialize auth
await authStore.initialize();
// Load data if authenticated
if (authStore.isAuthenticated) {
await Promise.all([projectsStore.fetchProjects(), labelsStore.fetchLabels()]);
}
// Initialize sidebar mode from localStorage
const savedSidebar = localStorage.getItem('todo-nav-sidebar');
if (savedSidebar === 'true') {
isSidebarMode = true;
}
// Initialize collapsed state from localStorage
const savedCollapsed = localStorage.getItem('todo-nav-collapsed');
if (savedCollapsed === 'true') {
isCollapsed = true;
}
loading = false;
});
</script>
<svelte:window onkeydown={handleKeydown} />
{#if isAuthRoute}
<!-- Auth routes: no navigation, just render content -->
{@render children()}
{:else if loading}
<div class="flex min-h-screen items-center justify-center bg-background">
<div class="text-center">
<div
class="mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
></div>
<p class="text-muted-foreground">Laden...</p>
</div>
</div>
{:else}
<div class="layout-container">
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Todo"
homeRoute="/"
onToggleTheme={handleToggleTheme}
{isDark}
{isSidebarMode}
onModeChange={handleModeChange}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
desktopPosition="bottom"
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={authStore.isAuthenticated}
onLogout={handleLogout}
loginHref="/login"
primaryColor="#8b5cf6"
showAppSwitcher={true}
{appItems}
{userEmail}
settingsHref="/settings"
manaHref="/mana"
profileHref="/profile"
allAppsHref="/apps"
/>
<main
class="main-content bg-background"
class:sidebar-mode={isSidebarMode && !isCollapsed}
class:floating-mode={!isSidebarMode && !isCollapsed}
>
<div class="content-wrapper">
{@render children()}
</div>
</main>
</div>
{/if}
<style>
.layout-container {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.main-content {
transition: all 300ms ease;
position: relative;
z-index: 0;
}
.main-content.floating-mode {
padding-top: 70px;
}
.main-content.sidebar-mode {
padding-left: 180px;
}
.content-wrapper {
max-width: 900px;
margin-left: auto;
margin-right: auto;
padding: 1rem;
position: relative;
z-index: 0;
}
@media (min-width: 640px) {
.content-wrapper {
padding: 1.5rem;
}
}
@media (min-width: 1024px) {
.content-wrapper {
padding: 2rem;
}
}
</style>

View file

@ -0,0 +1,74 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { tasksStore } from '$lib/stores/tasks.svelte';
import { viewStore } from '$lib/stores/view.svelte';
import TaskList from '$lib/components/TaskList.svelte';
import QuickAddTask from '$lib/components/QuickAddTask.svelte';
let isLoading = $state(true);
onMount(async () => {
// Redirect to login if not authenticated
if (!authStore.isAuthenticated) {
goto('/login');
return;
}
// Set view to inbox
viewStore.setInbox();
// Fetch inbox tasks
await tasksStore.fetchInboxTasks();
isLoading = false;
});
</script>
<svelte:head>
<title>Inbox | Todo</title>
</svelte:head>
<div class="inbox-view">
<header class="mb-6">
<h1 class="text-2xl font-bold text-foreground">Inbox</h1>
<p class="text-muted-foreground text-sm mt-1">Aufgaben ohne Projekt</p>
</header>
<QuickAddTask />
{#if isLoading || tasksStore.loading}
<div class="flex items-center justify-center py-12">
<div
class="animate-spin h-8 w-8 border-4 border-primary border-r-transparent rounded-full"
></div>
</div>
{:else if tasksStore.error}
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-4 rounded-lg">
{tasksStore.error}
</div>
{:else if tasksStore.incompleteTasks.length === 0}
<div class="text-center py-12">
<div class="text-6xl mb-4">📥</div>
<h3 class="text-lg font-medium text-foreground mb-2">Inbox ist leer</h3>
<p class="text-muted-foreground">Füge eine neue Aufgabe hinzu, um loszulegen.</p>
</div>
{:else}
<TaskList tasks={tasksStore.incompleteTasks} />
{/if}
{#if viewStore.showCompleted && tasksStore.completedTasks.length > 0}
<div class="mt-8">
<h2 class="text-lg font-semibold text-muted-foreground mb-4">
Erledigt ({tasksStore.completedTasks.length})
</h2>
<TaskList tasks={tasksStore.completedTasks} showCompleted />
</div>
{/if}
</div>
<style>
.inbox-view {
padding-bottom: 100px;
}
</style>

View file

@ -0,0 +1,58 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { tasksStore } from '$lib/stores/tasks.svelte';
import { viewStore } from '$lib/stores/view.svelte';
import TaskList from '$lib/components/TaskList.svelte';
let isLoading = $state(true);
onMount(async () => {
if (!authStore.isAuthenticated) {
goto('/login');
return;
}
viewStore.setCompleted();
await tasksStore.fetchTasks({ isCompleted: true });
isLoading = false;
});
</script>
<svelte:head>
<title>Erledigt | Todo</title>
</svelte:head>
<div class="completed-view">
<header class="mb-6">
<h1 class="text-2xl font-bold text-foreground">Erledigt</h1>
<p class="text-muted-foreground text-sm mt-1">Erledigte Aufgaben</p>
</header>
{#if isLoading || tasksStore.loading}
<div class="flex items-center justify-center py-12">
<div
class="animate-spin h-8 w-8 border-4 border-primary border-r-transparent rounded-full"
></div>
</div>
{:else if tasksStore.error}
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-4 rounded-lg">
{tasksStore.error}
</div>
{:else if tasksStore.completedTasks.length === 0}
<div class="text-center py-12">
<div class="text-6xl mb-4"></div>
<h3 class="text-lg font-medium text-foreground mb-2">Noch nichts erledigt</h3>
<p class="text-muted-foreground">Erledigte Aufgaben werden hier angezeigt.</p>
</div>
{:else}
<TaskList tasks={tasksStore.completedTasks} showCompleted />
{/if}
</div>
<style>
.completed-view {
padding-bottom: 100px;
}
</style>

View file

@ -0,0 +1,22 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { FeedbackPage } from '@manacore/shared-feedback-ui';
onMount(() => {
if (!authStore.isAuthenticated) {
goto('/login');
}
});
</script>
<svelte:head>
<title>Feedback | Todo</title>
</svelte:head>
<FeedbackPage
appName="Todo"
userEmail={authStore.user?.email || undefined}
primaryColor="#8b5cf6"
/>

View file

@ -0,0 +1,76 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
onMount(() => {
if (!authStore.isAuthenticated) {
goto('/login');
}
});
</script>
<svelte:head>
<title>Einstellungen | Todo</title>
</svelte:head>
<div class="settings-view">
<header class="mb-6">
<h1 class="text-2xl font-bold text-foreground">Einstellungen</h1>
</header>
<div class="space-y-6">
<!-- Account Section -->
<section class="bg-card rounded-lg border border-border p-6">
<h2 class="text-lg font-semibold text-foreground mb-4">Konto</h2>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-foreground">E-Mail</p>
<p class="text-sm text-muted-foreground">
{authStore.user?.email || 'Nicht angemeldet'}
</p>
</div>
</div>
<div class="pt-4 border-t border-border">
<button
class="text-red-500 hover:text-red-600 text-sm font-medium"
onclick={() => {
authStore.signOut();
goto('/login');
}}
>
Abmelden
</button>
</div>
</div>
</section>
<!-- Appearance Section -->
<section class="bg-card rounded-lg border border-border p-6">
<h2 class="text-lg font-semibold text-foreground mb-4">Erscheinungsbild</h2>
<p class="text-sm text-muted-foreground">
Theme-Einstellungen sind in der Navigation verfügbar.
</p>
</section>
<!-- About Section -->
<section class="bg-card rounded-lg border border-border p-6">
<h2 class="text-lg font-semibold text-foreground mb-4">Über</h2>
<div class="space-y-2">
<p class="text-sm text-muted-foreground">Todo App v1.0.0</p>
<p class="text-sm text-muted-foreground">Teil des ManaCore Ökosystems</p>
</div>
</section>
</div>
</div>
<style>
.settings-view {
padding-bottom: 100px;
}
</style>

View file

@ -0,0 +1,91 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { tasksStore } from '$lib/stores/tasks.svelte';
import { viewStore } from '$lib/stores/view.svelte';
import TaskList from '$lib/components/TaskList.svelte';
import QuickAddTask from '$lib/components/QuickAddTask.svelte';
let isLoading = $state(true);
onMount(async () => {
if (!authStore.isAuthenticated) {
goto('/login');
return;
}
viewStore.setToday();
await tasksStore.fetchTodayTasks();
isLoading = false;
});
// Separate overdue and today tasks
let overdueTasks = $derived(tasksStore.overdueTasks);
let todayTasks = $derived(tasksStore.todayTasks);
</script>
<svelte:head>
<title>Heute | Todo</title>
</svelte:head>
<div class="today-view">
<header class="mb-6">
<h1 class="text-2xl font-bold text-foreground">Heute</h1>
<p class="text-muted-foreground text-sm mt-1">Fällige Aufgaben für heute</p>
</header>
<QuickAddTask />
{#if isLoading || tasksStore.loading}
<div class="flex items-center justify-center py-12">
<div
class="animate-spin h-8 w-8 border-4 border-primary border-r-transparent rounded-full"
></div>
</div>
{:else if tasksStore.error}
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-4 rounded-lg">
{tasksStore.error}
</div>
{:else}
{#if overdueTasks.length > 0}
<div class="mb-6">
<h2 class="text-sm font-semibold text-red-500 mb-3 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
Überfällig ({overdueTasks.length})
</h2>
<TaskList tasks={overdueTasks} />
</div>
{/if}
{#if todayTasks.length > 0}
<div>
<h2 class="text-sm font-semibold text-muted-foreground mb-3">
Heute ({todayTasks.length})
</h2>
<TaskList tasks={todayTasks} />
</div>
{/if}
{#if overdueTasks.length === 0 && todayTasks.length === 0}
<div class="text-center py-12">
<div class="text-6xl mb-4">🎉</div>
<h3 class="text-lg font-medium text-foreground mb-2">Alles erledigt!</h3>
<p class="text-muted-foreground">Keine Aufgaben für heute.</p>
</div>
{/if}
{/if}
</div>
<style>
.today-view {
padding-bottom: 100px;
}
</style>

View file

@ -0,0 +1,100 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { format, addDays, startOfDay } from 'date-fns';
import { de } from 'date-fns/locale';
import { authStore } from '$lib/stores/auth.svelte';
import { tasksStore } from '$lib/stores/tasks.svelte';
import { viewStore } from '$lib/stores/view.svelte';
import TaskList from '$lib/components/TaskList.svelte';
import type { Task } from '@todo/shared';
let isLoading = $state(true);
onMount(async () => {
if (!authStore.isAuthenticated) {
goto('/login');
return;
}
viewStore.setUpcoming();
await tasksStore.fetchUpcomingTasks();
isLoading = false;
});
// Group tasks by day
let groupedTasks = $derived(() => {
const groups: { date: Date; label: string; tasks: Task[] }[] = [];
const today = startOfDay(new Date());
for (let i = 0; i < 7; i++) {
const date = addDays(today, i);
const dayTasks = tasksStore.tasks.filter((task) => {
if (!task.dueDate || task.isCompleted) return false;
const taskDate = startOfDay(new Date(task.dueDate));
return taskDate.getTime() === date.getTime();
});
if (dayTasks.length > 0) {
let label: string;
if (i === 0) {
label = 'Heute';
} else if (i === 1) {
label = 'Morgen';
} else {
label = format(date, 'EEEE, d. MMMM', { locale: de });
}
groups.push({ date, label, tasks: dayTasks });
}
}
return groups;
});
</script>
<svelte:head>
<title>Demnächst | Todo</title>
</svelte:head>
<div class="upcoming-view">
<header class="mb-6">
<h1 class="text-2xl font-bold text-foreground">Demnächst</h1>
<p class="text-muted-foreground text-sm mt-1">Aufgaben der nächsten 7 Tage</p>
</header>
{#if isLoading || tasksStore.loading}
<div class="flex items-center justify-center py-12">
<div
class="animate-spin h-8 w-8 border-4 border-primary border-r-transparent rounded-full"
></div>
</div>
{:else if tasksStore.error}
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-4 rounded-lg">
{tasksStore.error}
</div>
{:else if groupedTasks().length === 0}
<div class="text-center py-12">
<div class="text-6xl mb-4">📅</div>
<h3 class="text-lg font-medium text-foreground mb-2">Keine anstehenden Aufgaben</h3>
<p class="text-muted-foreground">Keine Aufgaben in den nächsten 7 Tagen geplant.</p>
</div>
{:else}
<div class="space-y-8">
{#each groupedTasks() as group}
<div>
<h2 class="text-sm font-semibold text-muted-foreground mb-3">
{group.label} ({group.tasks.length})
</h2>
<TaskList tasks={group.tasks} />
</div>
{/each}
</div>
{/if}
</div>
<style>
.upcoming-view {
padding-bottom: 100px;
}
</style>

View file

View file

@ -0,0 +1,12 @@
import adapter from '@sveltejs/adapter-auto';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter(),
},
};
export default config;

View file

@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}

View file

@ -0,0 +1,45 @@
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
server: {
port: 5188,
strictPort: true,
},
ssr: {
noExternal: [
'@todo/shared',
'@manacore/shared-icons',
'@manacore/shared-ui',
'@manacore/shared-tailwind',
'@manacore/shared-theme',
'@manacore/shared-theme-ui',
'@manacore/shared-feedback-ui',
'@manacore/shared-feedback-service',
'@manacore/shared-feedback-types',
'@manacore/shared-auth',
'@manacore/shared-auth-ui',
'@manacore/shared-branding',
'@manacore/shared-subscription-ui',
],
},
optimizeDeps: {
exclude: [
'@todo/shared',
'@manacore/shared-icons',
'@manacore/shared-ui',
'@manacore/shared-tailwind',
'@manacore/shared-theme',
'@manacore/shared-theme-ui',
'@manacore/shared-feedback-ui',
'@manacore/shared-feedback-service',
'@manacore/shared-feedback-types',
'@manacore/shared-auth',
'@manacore/shared-auth-ui',
'@manacore/shared-branding',
'@manacore/shared-subscription-ui',
],
},
});

22
apps/todo/package.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "todo",
"version": "1.0.0",
"private": true,
"description": "Todo App - Task Management for ManaCore Ecosystem",
"scripts": {
"dev": "turbo run dev",
"dev:backend": "pnpm --filter @todo/backend dev",
"dev:web": "pnpm --filter @todo/web dev",
"dev:landing": "pnpm --filter @todo/landing dev",
"build": "turbo run build",
"lint": "turbo run lint",
"clean": "turbo run clean",
"db:push": "pnpm --filter @todo/backend db:push",
"db:studio": "pnpm --filter @todo/backend db:studio",
"db:seed": "pnpm --filter @todo/backend db:seed"
},
"devDependencies": {
"typescript": "^5.9.3"
},
"packageManager": "pnpm@9.15.0"
}

View file

@ -0,0 +1,20 @@
{
"name": "@todo/shared",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./types": "./src/types/index.ts",
"./utils": "./src/utils/index.ts",
"./constants": "./src/constants/index.ts"
},
"scripts": {
"type-check": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.9.3"
}
}

View file

@ -0,0 +1,88 @@
// Default project colors
export const DEFAULT_PROJECT_COLORS = [
'#3B82F6', // Blue
'#EF4444', // Red
'#10B981', // Green
'#F59E0B', // Amber
'#8B5CF6', // Violet
'#EC4899', // Pink
'#06B6D4', // Cyan
'#F97316', // Orange
] as const;
// Default label colors
export const DEFAULT_LABEL_COLORS = [
'#6B7280', // Gray
'#EF4444', // Red
'#F59E0B', // Amber
'#10B981', // Green
'#3B82F6', // Blue
'#8B5CF6', // Violet
'#EC4899', // Pink
'#14B8A6', // Teal
] as const;
// Priority configuration
export const PRIORITY_CONFIG = {
low: {
label: 'Low',
color: '#6B7280',
order: 0,
},
medium: {
label: 'Medium',
color: '#3B82F6',
order: 1,
},
high: {
label: 'High',
color: '#F59E0B',
order: 2,
},
urgent: {
label: 'Urgent',
color: '#EF4444',
order: 3,
},
} as const;
// Recurrence presets
export const RECURRENCE_PRESETS = [
{ label: 'Daily', rule: 'FREQ=DAILY' },
{ label: 'Weekdays', rule: 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR' },
{ label: 'Weekly', rule: 'FREQ=WEEKLY' },
{ label: 'Bi-weekly', rule: 'FREQ=WEEKLY;INTERVAL=2' },
{ label: 'Monthly', rule: 'FREQ=MONTHLY' },
{ label: 'Yearly', rule: 'FREQ=YEARLY' },
] as const;
// Reminder presets (in minutes)
export const REMINDER_PRESETS = [
{ label: 'At time of task', minutes: 0 },
{ label: '5 minutes before', minutes: 5 },
{ label: '15 minutes before', minutes: 15 },
{ label: '30 minutes before', minutes: 30 },
{ label: '1 hour before', minutes: 60 },
{ label: '2 hours before', minutes: 120 },
{ label: '1 day before', minutes: 1440 },
{ label: '2 days before', minutes: 2880 },
{ label: '1 week before', minutes: 10080 },
] as const;
// View types
export type ViewType =
| 'inbox'
| 'today'
| 'upcoming'
| 'project'
| 'label'
| 'completed'
| 'search';
// Sort options
export const SORT_OPTIONS = [
{ value: 'order', label: 'Manual' },
{ value: 'dueDate', label: 'Due Date' },
{ value: 'priority', label: 'Priority' },
{ value: 'createdAt', label: 'Created' },
] as const;

View file

@ -0,0 +1,8 @@
// Types
export * from './types';
// Constants
export * from './constants';
// Utils
export * from './utils';

View file

@ -0,0 +1,4 @@
export * from './project';
export * from './task';
export * from './label';
export * from './reminder';

View file

@ -0,0 +1,18 @@
export interface Label {
id: string;
userId: string;
name: string;
color: string;
createdAt: Date | string;
updatedAt: Date | string;
}
export interface CreateLabelInput {
name: string;
color?: string;
}
export interface UpdateLabelInput {
name?: string;
color?: string;
}

View file

@ -0,0 +1,44 @@
export interface ProjectSettings {
defaultView?: 'list' | 'board';
showCompletedTasks?: boolean;
sortBy?: 'dueDate' | 'priority' | 'createdAt' | 'order';
sortOrder?: 'asc' | 'desc';
}
export interface Project {
id: string;
userId: string;
name: string;
description?: string | null;
color: string;
icon?: string | null;
order: number;
isArchived: boolean;
isDefault: boolean;
settings?: ProjectSettings | null;
createdAt: Date | string;
updatedAt: Date | string;
}
export interface CreateProjectInput {
name: string;
description?: string;
color?: string;
icon?: string;
isDefault?: boolean;
settings?: ProjectSettings;
}
export interface UpdateProjectInput {
name?: string;
description?: string | null;
color?: string;
icon?: string | null;
isArchived?: boolean;
isDefault?: boolean;
settings?: ProjectSettings | null;
}
export interface ReorderProjectsInput {
projectIds: string[];
}

View file

@ -0,0 +1,19 @@
export type ReminderType = 'push' | 'email' | 'both';
export type ReminderStatus = 'pending' | 'sent' | 'failed' | 'cancelled';
export interface Reminder {
id: string;
taskId: string;
userId: string;
minutesBefore: number;
reminderTime: Date | string;
type: ReminderType;
status: ReminderStatus;
sentAt?: Date | string | null;
createdAt: Date | string;
}
export interface CreateReminderInput {
minutesBefore: number;
type?: ReminderType;
}

View file

@ -0,0 +1,128 @@
import type { Label } from './label';
export type TaskPriority = 'low' | 'medium' | 'high' | 'urgent';
export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled';
export interface Subtask {
id: string;
title: string;
isCompleted: boolean;
completedAt?: string | null;
order: number;
}
export interface TaskMetadata {
notes?: string;
attachments?: string[];
linkedCalendarEventId?: string | null;
}
export interface Task {
id: string;
projectId?: string | null;
userId: string;
parentTaskId?: string | null;
// Content
title: string;
description?: string | null;
// Scheduling
dueDate?: Date | string | null;
dueTime?: string | null; // HH:mm format
startDate?: Date | string | null;
// Priority & Status
priority: TaskPriority;
status: TaskStatus;
// Completion
isCompleted: boolean;
completedAt?: Date | string | null;
// Ordering
order: number;
// Recurrence
recurrenceRule?: string | null;
recurrenceEndDate?: Date | string | null;
lastOccurrence?: Date | string | null;
// Subtasks
subtasks?: Subtask[] | null;
// Metadata
metadata?: TaskMetadata | null;
// Relations (populated on fetch)
labels?: Label[];
createdAt: Date | string;
updatedAt: Date | string;
}
export interface CreateTaskInput {
projectId?: string | null;
parentTaskId?: string | null;
title: string;
description?: string;
dueDate?: string | null;
dueTime?: string | null;
startDate?: string | null;
priority?: TaskPriority;
recurrenceRule?: string | null;
recurrenceEndDate?: string | null;
subtasks?: Omit<Subtask, 'id'>[];
labelIds?: string[];
metadata?: TaskMetadata;
}
export interface UpdateTaskInput {
projectId?: string | null;
parentTaskId?: string | null;
title?: string;
description?: string | null;
dueDate?: string | null;
dueTime?: string | null;
startDate?: string | null;
priority?: TaskPriority;
status?: TaskStatus;
isCompleted?: boolean;
order?: number;
recurrenceRule?: string | null;
recurrenceEndDate?: string | null;
subtasks?: Subtask[] | null;
metadata?: TaskMetadata | null;
}
export interface QueryTasksInput {
projectId?: string;
labelId?: string;
priority?: TaskPriority;
status?: TaskStatus;
isCompleted?: boolean;
dueDateFrom?: string;
dueDateTo?: string;
search?: string;
sortBy?: 'dueDate' | 'priority' | 'createdAt' | 'order';
sortOrder?: 'asc' | 'desc';
limit?: number;
offset?: number;
}
export interface ReorderTasksInput {
taskIds: string[];
projectId?: string | null;
}
export interface MoveTaskInput {
projectId: string | null;
}
export interface UpdateTaskLabelsInput {
labelIds: string[];
}
export interface UpdateSubtasksInput {
subtasks: Subtask[];
}

View file

@ -0,0 +1,117 @@
import type { Task, TaskPriority } from '../types';
import { PRIORITY_CONFIG } from '../constants';
/**
* Generate a unique ID for subtasks
*/
export function generateSubtaskId(): string {
return `subtask_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
}
/**
* Check if a task is overdue
*/
export function isTaskOverdue(task: Task): boolean {
if (!task.dueDate || task.isCompleted) return false;
const dueDate = new Date(task.dueDate);
const today = new Date();
today.setHours(0, 0, 0, 0);
return dueDate < today;
}
/**
* Check if a task is due today
*/
export function isTaskDueToday(task: Task): boolean {
if (!task.dueDate || task.isCompleted) return false;
const dueDate = new Date(task.dueDate);
const today = new Date();
return (
dueDate.getFullYear() === today.getFullYear() &&
dueDate.getMonth() === today.getMonth() &&
dueDate.getDate() === today.getDate()
);
}
/**
* Get priority order for sorting
*/
export function getPriorityOrder(priority: TaskPriority): number {
return PRIORITY_CONFIG[priority]?.order ?? 1;
}
/**
* Get priority color
*/
export function getPriorityColor(priority: TaskPriority): string {
return PRIORITY_CONFIG[priority]?.color ?? '#6B7280';
}
/**
* Sort tasks by priority (highest first)
*/
export function sortByPriority(tasks: Task[]): Task[] {
return [...tasks].sort((a, b) => getPriorityOrder(b.priority) - getPriorityOrder(a.priority));
}
/**
* Sort tasks by due date (earliest first, null last)
*/
export function sortByDueDate(tasks: Task[]): Task[] {
return [...tasks].sort((a, b) => {
if (!a.dueDate && !b.dueDate) return 0;
if (!a.dueDate) return 1;
if (!b.dueDate) return -1;
return new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime();
});
}
/**
* Calculate subtask completion percentage
*/
export function getSubtaskProgress(task: Task): number {
if (!task.subtasks || task.subtasks.length === 0) return 0;
const completed = task.subtasks.filter((s) => s.isCompleted).length;
return Math.round((completed / task.subtasks.length) * 100);
}
/**
* Format due date for display
*/
export function formatDueDate(dueDate: string | Date, includeTime = false): string {
const date = new Date(dueDate);
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const isToday =
date.getFullYear() === today.getFullYear() &&
date.getMonth() === today.getMonth() &&
date.getDate() === today.getDate();
const isTomorrow =
date.getFullYear() === tomorrow.getFullYear() &&
date.getMonth() === tomorrow.getMonth() &&
date.getDate() === tomorrow.getDate();
let result: string;
if (isToday) {
result = 'Today';
} else if (isTomorrow) {
result = 'Tomorrow';
} else if (date.getFullYear() === today.getFullYear()) {
result = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
} else {
result = date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
if (includeTime) {
result += ` at ${date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}`;
}
return result;
}

View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"noEmit": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

View file

@ -10,8 +10,12 @@ CREATE DATABASE voxel_lava;
-- Create storage database (cloud drive)
CREATE DATABASE storage;
-- Create todo database
CREATE DATABASE todo;
-- Grant all privileges to the default user
GRANT ALL PRIVILEGES ON DATABASE chat TO manacore;
GRANT ALL PRIVILEGES ON DATABASE voxel_lava TO manacore;
GRANT ALL PRIVILEGES ON DATABASE manacore TO manacore;
GRANT ALL PRIVILEGES ON DATABASE storage TO manacore;
GRANT ALL PRIVILEGES ON DATABASE todo TO manacore;

View file

@ -157,6 +157,56 @@ export const APP_BRANDING: Record<AppId, AppBranding> = {
logoStroke: true,
logoStrokeWidth: 1.5,
},
mail: {
id: 'mail',
name: 'Mail',
tagline: 'Smart Email Client',
primaryColor: '#6366f1',
secondaryColor: '#818cf8',
// Envelope icon
logoPath:
'M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75',
logoViewBox: '0 0 24 24',
logoStroke: true,
logoStrokeWidth: 1.5,
},
storage: {
id: 'storage',
name: 'Storage',
tagline: 'Cloud Storage',
primaryColor: '#3b82f6',
secondaryColor: '#60a5fa',
// Cloud/storage icon
logoPath:
'M2.25 15a4.5 4.5 0 004.5 4.5H18a3.75 3.75 0 001.332-7.257 3 3 0 00-3.758-3.848 5.25 5.25 0 00-10.233 2.33A4.502 4.502 0 002.25 15z',
logoViewBox: '0 0 24 24',
logoStroke: true,
logoStrokeWidth: 1.5,
},
clock: {
id: 'clock',
name: 'Clock',
tagline: 'Clocks & Alarms',
primaryColor: '#f59e0b',
secondaryColor: '#fbbf24',
// Clock icon
logoPath: 'M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z',
logoViewBox: '0 0 24 24',
logoStroke: true,
logoStrokeWidth: 1.5,
},
todo: {
id: 'todo',
name: 'Todo',
tagline: 'Task Management',
primaryColor: '#8b5cf6',
secondaryColor: '#a78bfa',
// Checklist icon
logoPath: 'M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
logoViewBox: '0 0 24 24',
logoStroke: true,
logoStrokeWidth: 1.5,
},
};
/**

View file

@ -26,6 +26,9 @@ export {
ZitareLogo,
ContactsLogo,
CalendarLogo,
StorageLogo,
TodoLogo,
MailLogo,
} from './logos';
// Configuration

View file

@ -0,0 +1,13 @@
<script lang="ts">
import AppLogo from '../AppLogo.svelte';
interface Props {
size?: number;
color?: string;
class?: string;
}
let { size = 55, color, class: className = '' }: Props = $props();
</script>
<AppLogo app="todo" {size} {color} class={className} />

View file

@ -14,3 +14,5 @@ export { default as ZitareLogo } from './ZitareLogo.svelte';
export { default as ContactsLogo } from './ContactsLogo.svelte';
export { default as CalendarLogo } from './CalendarLogo.svelte';
export { default as StorageLogo } from './StorageLogo.svelte';
export { default as TodoLogo } from './TodoLogo.svelte';
export { default as MailLogo } from './MailLogo.svelte';

View file

@ -13,7 +13,11 @@ export type AppId =
| 'zitare'
| 'picture'
| 'contacts'
| 'calendar';
| 'calendar'
| 'storage'
| 'clock'
| 'todo'
| 'mail';
/**
* App branding configuration