diff --git a/apps/manacore/apps/web/src/lib/data/database.ts b/apps/manacore/apps/web/src/lib/data/database.ts index 20002e204..3f043b8c2 100644 --- a/apps/manacore/apps/web/src/lib/data/database.ts +++ b/apps/manacore/apps/web/src/lib/data/database.ts @@ -179,6 +179,15 @@ db.version(1).stores({ habits: 'id, order, isArchived, color', habitLogs: 'id, habitId, timestamp, [habitId+timestamp]', + // ─── Notes (appId: 'notes') ─── + notes: 'id, isPinned, isArchived, color, title, updatedAt', + noteTags: 'id, noteId, tagId, [noteId+tagId]', + + // ─── Finance (appId: 'finance') ─── + transactions: 'id, type, categoryId, date, amount, [date+type], [categoryId+date]', + financeCategories: 'id, type, order', + budgets: 'id, categoryId, month, [month+categoryId]', + // ─── Shared: Global Tags (appId: 'tags') ─── globalTags: 'id, name, groupId', tagGroups: 'id', @@ -228,6 +237,8 @@ export const SYNC_APP_MAP: Record = { memoro: ['memos', 'memories', 'memoTags', 'memoroSpaces', 'spaceMembers', 'memoSpaces'], guides: ['guides', 'sections', 'steps', 'guideCollections', 'runs', 'guideTags'], habits: ['habits', 'habitLogs'], + notes: ['notes', 'noteTags'], + finance: ['transactions', 'financeCategories', 'budgets'], tags: ['globalTags', 'tagGroups'], links: ['manaLinks'], }; @@ -294,6 +305,8 @@ export const TABLE_TO_SYNC_NAME: Record = { uloadFolders: 'folders', // guides guideCollections: 'collections', + // finance + financeCategories: 'categories', // shared: tags globalTags: 'tags', tagGroups: 'tagGroups', diff --git a/apps/manacore/apps/web/src/lib/modules/finance/ListView.svelte b/apps/manacore/apps/web/src/lib/modules/finance/ListView.svelte new file mode 100644 index 000000000..4a902cfe1 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/finance/ListView.svelte @@ -0,0 +1,470 @@ + + + +
+ +
+
+ Einnahmen + +{formatCurrency(income)} +
+
+ Ausgaben + -{formatCurrency(expenses)} +
+
+ Bilanz + = 0} class:expense={balance < 0}> + {balance >= 0 ? '+' : ''}{formatCurrency(balance)} + +
+
+ + + {#if !showAdd} + + {/if} + + + {#if showAdd} +
+
+ + +
+
+ + \u20ac +
+ +
+ {#each filteredCats as cat (cat.id)} + + {/each} +
+
+ + +
+
+ {/if} + + + {#if recentTxs.length > 0} +
+ {#each [...grouped.entries()] as [date, dayTxs] (date)} +
{formatDateLabel(date)}
+ {#each dayTxs as tx (tx.id)} + {@const cat = tx.categoryId ? catMap.get(tx.categoryId) : null} +
+ {cat?.emoji ?? '\ud83d\udcb3'} +
+ {tx.description} + {#if cat}{cat.name}{/if} +
+ + {tx.type === 'income' ? '+' : '-'}{formatCurrency(tx.amount)} + +
+ {/each} + {/each} +
+ {/if} + + {#if txs.length === 0 && !showAdd} +
+

Noch keine Transaktionen.

+ +
+ {/if} +
+ + diff --git a/apps/manacore/apps/web/src/lib/modules/finance/collections.ts b/apps/manacore/apps/web/src/lib/modules/finance/collections.ts new file mode 100644 index 000000000..202ee4908 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/finance/collections.ts @@ -0,0 +1,73 @@ +/** + * Finance module — collection accessors and guest seed data. + */ + +import { db } from '$lib/data/database'; +import type { LocalTransaction, LocalFinanceCategory, LocalBudget } from './types'; +import { DEFAULT_EXPENSE_CATEGORIES, DEFAULT_INCOME_CATEGORIES } from './types'; + +// ─── Collection Accessors ────────────────────────────────── + +export const transactionTable = db.table('transactions'); +export const categoryTable = db.table('financeCategories'); +export const budgetTable = db.table('budgets'); + +// ─── Guest Seed ──────────────────────────────────────────── + +function todayStr(): string { + return new Date().toISOString().split('T')[0]; +} + +function daysAgoStr(n: number): string { + const d = new Date(); + d.setDate(d.getDate() - n); + return d.toISOString().split('T')[0]; +} + +const SEED_CATEGORIES = [ + ...DEFAULT_EXPENSE_CATEGORIES.map((c, i) => ({ ...c, id: `cat-exp-${i}` })), + ...DEFAULT_INCOME_CATEGORIES.map((c, i) => ({ ...c, id: `cat-inc-${i}` })), +]; + +export const FINANCE_GUEST_SEED = { + financeCategories: SEED_CATEGORIES satisfies LocalFinanceCategory[], + transactions: [ + { + id: 'tx-1', + type: 'expense' as const, + amount: 12.5, + categoryId: 'cat-exp-0', + description: 'Mittagessen', + date: todayStr(), + note: null, + }, + { + id: 'tx-2', + type: 'expense' as const, + amount: 2.9, + categoryId: 'cat-exp-0', + description: 'Kaffee', + date: todayStr(), + note: null, + }, + { + id: 'tx-3', + type: 'expense' as const, + amount: 49.99, + categoryId: 'cat-exp-6', + description: 'Spotify + Netflix', + date: daysAgoStr(2), + note: null, + }, + { + id: 'tx-4', + type: 'income' as const, + amount: 3200, + categoryId: 'cat-inc-0', + description: 'Gehalt März', + date: daysAgoStr(5), + note: null, + }, + ] satisfies LocalTransaction[], + budgets: [] satisfies LocalBudget[], +}; diff --git a/apps/manacore/apps/web/src/lib/modules/finance/entity.ts b/apps/manacore/apps/web/src/lib/modules/finance/entity.ts new file mode 100644 index 000000000..cdc33d08d --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/finance/entity.ts @@ -0,0 +1,28 @@ +import { registerEntity } from '$lib/entities/registry'; +import { financeStore } from './stores/finance.svelte'; +import type { EntityDescriptor } from '$lib/entities/types'; + +const financeEntity: EntityDescriptor = { + appId: 'finance', + collection: 'transactions', + paramKey: 'transactionId', + + getDisplayData: (item) => ({ + title: (item.description as string) || 'Transaktion', + subtitle: item.amount ? `${item.type === 'income' ? '+' : '-'}${item.amount}` : undefined, + }), + + dragType: 'transaction', + acceptsDropFrom: [], + + createItem: async (data) => { + const tx = await financeStore.addTransaction({ + type: 'expense', + amount: (data.amount as number) ?? 0, + description: (data.title as string) ?? (data.description as string) ?? '', + }); + return tx.id; + }, +}; + +registerEntity(financeEntity); diff --git a/apps/manacore/apps/web/src/lib/modules/finance/index.ts b/apps/manacore/apps/web/src/lib/modules/finance/index.ts new file mode 100644 index 000000000..7336ce5e3 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/finance/index.ts @@ -0,0 +1,38 @@ +/** + * Finance module — barrel exports. + */ + +// ─── Stores ────────────────────────────────────────────── +export { financeStore } from './stores/finance.svelte'; + +// ─── Queries ───────────────────────────────────────────── +export { + useAllTransactions, + useAllCategories, + toTransaction, + toCategory, + currentMonth, + todayStr, + getTransactionsForMonth, + getMonthTotal, + getMonthBalance, + groupByDate, + getSpendingByCategory, + formatCurrency, + formatDateLabel, +} from './queries'; + +// ─── Collections ───────────────────────────────────────── +export { transactionTable, categoryTable, budgetTable, FINANCE_GUEST_SEED } from './collections'; + +// ─── Types ─────────────────────────────────────────────── +export { CATEGORY_COLORS } from './types'; +export type { + LocalTransaction, + LocalFinanceCategory, + LocalBudget, + Transaction, + FinanceCategory, + Budget, + TransactionType, +} from './types'; diff --git a/apps/manacore/apps/web/src/lib/modules/finance/queries.ts b/apps/manacore/apps/web/src/lib/modules/finance/queries.ts new file mode 100644 index 000000000..90430f33e --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/finance/queries.ts @@ -0,0 +1,133 @@ +/** + * Reactive Queries & Pure Helpers for Finance module. + */ + +import { liveQuery } from 'dexie'; +import { db } from '$lib/data/database'; +import type { + LocalTransaction, + LocalFinanceCategory, + Transaction, + FinanceCategory, + TransactionType, +} from './types'; + +// ─── Type Converters ─────────────────────────────────────── + +export function toTransaction(local: LocalTransaction): Transaction { + return { + id: local.id, + type: local.type, + amount: local.amount, + categoryId: local.categoryId, + description: local.description, + date: local.date, + note: local.note, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +export function toCategory(local: LocalFinanceCategory): FinanceCategory { + return { + id: local.id, + name: local.name, + emoji: local.emoji, + color: local.color, + type: local.type, + order: local.order, + createdAt: local.createdAt ?? new Date().toISOString(), + }; +} + +// ─── Live Queries ────────────────────────────────────────── + +export function useAllTransactions() { + return liveQuery(async () => { + const locals = await db.table('transactions').toArray(); + return locals + .filter((t) => !t.deletedAt) + .map(toTransaction) + .sort((a, b) => b.date.localeCompare(a.date) || b.createdAt.localeCompare(a.createdAt)); + }); +} + +export function useAllCategories() { + return liveQuery(async () => { + const locals = await db.table('financeCategories').toArray(); + return locals + .filter((c) => !c.deletedAt) + .map(toCategory) + .sort((a, b) => a.order - b.order); + }); +} + +// ─── Pure Helpers ────────────────────────────────────────── + +/** Current month string YYYY-MM */ +export function currentMonth(): string { + return new Date().toISOString().slice(0, 7); +} + +/** Today string YYYY-MM-DD */ +export function todayStr(): string { + return new Date().toISOString().split('T')[0]; +} + +/** Filter transactions for a month (YYYY-MM) */ +export function getTransactionsForMonth(txs: Transaction[], month: string): Transaction[] { + return txs.filter((t) => t.date.startsWith(month)); +} + +/** Get total for a type in a month */ +export function getMonthTotal(txs: Transaction[], month: string, type: TransactionType): number { + return getTransactionsForMonth(txs, month) + .filter((t) => t.type === type) + .reduce((sum, t) => sum + t.amount, 0); +} + +/** Get balance (income - expenses) for a month */ +export function getMonthBalance(txs: Transaction[], month: string): number { + const income = getMonthTotal(txs, month, 'income'); + const expenses = getMonthTotal(txs, month, 'expense'); + return income - expenses; +} + +/** Group transactions by date */ +export function groupByDate(txs: Transaction[]): Map { + const groups = new Map(); + for (const tx of txs) { + const existing = groups.get(tx.date) || []; + existing.push(tx); + groups.set(tx.date, existing); + } + return groups; +} + +/** Get spending by category for a month */ +export function getSpendingByCategory(txs: Transaction[], month: string): Map { + const result = new Map(); + for (const tx of getTransactionsForMonth(txs, month)) { + if (tx.type !== 'expense' || !tx.categoryId) continue; + result.set(tx.categoryId, (result.get(tx.categoryId) ?? 0) + tx.amount); + } + return result; +} + +/** Format currency */ +export function formatCurrency(amount: number): string { + return amount.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' }); +} + +/** Format date label */ +export function formatDateLabel(date: string): string { + const today = todayStr(); + const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0]; + if (date === today) return 'Heute'; + if (date === yesterday) return 'Gestern'; + return new Date(date).toLocaleDateString('de-DE', { + weekday: 'short', + day: 'numeric', + month: 'short', + }); +} diff --git a/apps/manacore/apps/web/src/lib/modules/finance/stores/finance.svelte.ts b/apps/manacore/apps/web/src/lib/modules/finance/stores/finance.svelte.ts new file mode 100644 index 000000000..2ff2c5733 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/finance/stores/finance.svelte.ts @@ -0,0 +1,73 @@ +/** + * Finance Store — Mutation-Only Service + */ + +import { transactionTable, categoryTable } from '../collections'; +import { toTransaction, toCategory } from '../queries'; +import type { LocalTransaction, LocalFinanceCategory, TransactionType } from '../types'; + +export const financeStore = { + async addTransaction(data: { + type: TransactionType; + amount: number; + categoryId?: string | null; + description: string; + date?: string; + note?: string; + }) { + const newLocal: LocalTransaction = { + id: crypto.randomUUID(), + type: data.type, + amount: Math.abs(data.amount), + categoryId: data.categoryId ?? null, + description: data.description, + date: data.date ?? new Date().toISOString().split('T')[0], + note: data.note ?? null, + }; + + await transactionTable.add(newLocal); + return toTransaction(newLocal); + }, + + async updateTransaction( + id: string, + data: Partial< + Pick + > + ) { + await transactionTable.update(id, { + ...data, + updatedAt: new Date().toISOString(), + }); + }, + + async deleteTransaction(id: string) { + await transactionTable.update(id, { + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + }, + + async addCategory(data: { name: string; emoji: string; color: string; type: TransactionType }) { + const existing = await categoryTable.toArray(); + const count = existing.filter((c) => !c.deletedAt && c.type === data.type).length; + + const newLocal: LocalFinanceCategory = { + id: crypto.randomUUID(), + name: data.name, + emoji: data.emoji, + color: data.color, + type: data.type, + order: count, + }; + + await categoryTable.add(newLocal); + return toCategory(newLocal); + }, + + async deleteCategory(id: string) { + await categoryTable.update(id, { + deletedAt: new Date().toISOString(), + }); + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/finance/types.ts b/apps/manacore/apps/web/src/lib/modules/finance/types.ts new file mode 100644 index 000000000..019e2b165 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/finance/types.ts @@ -0,0 +1,100 @@ +/** + * Finance module types. + * + * Simple income/expense tracking with categories and monthly budgets. + */ + +import type { BaseRecord } from '@manacore/local-store'; + +// ─── Local Record Types (Dexie) ─────────────────────────── + +export type TransactionType = 'income' | 'expense'; + +export interface LocalTransaction extends BaseRecord { + type: TransactionType; + amount: number; // always positive, type determines sign + categoryId: string | null; + description: string; + date: string; // YYYY-MM-DD + note: string | null; +} + +export interface LocalFinanceCategory extends BaseRecord { + name: string; + emoji: string; + color: string; + type: TransactionType; + order: number; +} + +export interface LocalBudget extends BaseRecord { + categoryId: string; + month: string; // YYYY-MM + amount: number; +} + +// ─── Domain Types ───────────────────────────────────────── + +export interface Transaction { + id: string; + type: TransactionType; + amount: number; + categoryId: string | null; + description: string; + date: string; + note: string | null; + createdAt: string; + updatedAt: string; +} + +export interface FinanceCategory { + id: string; + name: string; + emoji: string; + color: string; + type: TransactionType; + order: number; + createdAt: string; +} + +export interface Budget { + id: string; + categoryId: string; + month: string; + amount: number; +} + +// ─── Constants ──────────────────────────────────────────── + +export const DEFAULT_EXPENSE_CATEGORIES: Omit[] = [ + { name: 'Essen', emoji: '\ud83c\udf54', color: '#f97316', type: 'expense', order: 0 }, + { name: 'Transport', emoji: '\ud83d\ude8c', color: '#3b82f6', type: 'expense', order: 1 }, + { name: 'Einkaufen', emoji: '\ud83d\udecd\ufe0f', color: '#ec4899', type: 'expense', order: 2 }, + { name: 'Wohnung', emoji: '\ud83c\udfe0', color: '#8b5cf6', type: 'expense', order: 3 }, + { name: 'Unterhaltung', emoji: '\ud83c\udfac', color: '#ef4444', type: 'expense', order: 4 }, + { name: 'Gesundheit', emoji: '\ud83d\udc8a', color: '#22c55e', type: 'expense', order: 5 }, + { name: 'Abos', emoji: '\ud83d\udd01', color: '#06b6d4', type: 'expense', order: 6 }, + { name: 'Sonstiges', emoji: '\ud83d\udce6', color: '#6b7280', type: 'expense', order: 7 }, +]; + +export const DEFAULT_INCOME_CATEGORIES: Omit[] = [ + { name: 'Gehalt', emoji: '\ud83d\udcb0', color: '#22c55e', type: 'income', order: 0 }, + { name: 'Freelance', emoji: '\ud83d\udcbb', color: '#3b82f6', type: 'income', order: 1 }, + { name: 'Sonstiges', emoji: '\ud83d\udcb8', color: '#6b7280', type: 'income', order: 2 }, +]; + +export const CATEGORY_COLORS: string[] = [ + '#ef4444', + '#f97316', + '#f59e0b', + '#84cc16', + '#22c55e', + '#14b8a6', + '#06b6d4', + '#3b82f6', + '#6366f1', + '#8b5cf6', + '#a855f7', + '#d946ef', + '#ec4899', +]; diff --git a/apps/manacore/apps/web/src/lib/modules/habits/entity.ts b/apps/manacore/apps/web/src/lib/modules/habits/entity.ts index c66388d31..972accc53 100644 --- a/apps/manacore/apps/web/src/lib/modules/habits/entity.ts +++ b/apps/manacore/apps/web/src/lib/modules/habits/entity.ts @@ -5,6 +5,7 @@ import type { EntityDescriptor } from '$lib/entities/types'; const habitsEntity: EntityDescriptor = { appId: 'habits', collection: 'habits', + paramKey: 'habitId', getDisplayData: (item) => ({ title: `${item.emoji as string} ${item.title as string}`, diff --git a/apps/manacore/apps/web/src/lib/modules/notes/ListView.svelte b/apps/manacore/apps/web/src/lib/modules/notes/ListView.svelte new file mode 100644 index 000000000..be6f7a49b --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/notes/ListView.svelte @@ -0,0 +1,411 @@ + + + +
+ +
+ + +
+ + + {#if showCreate} +
+ + + +
+ {/if} + + +
+ {#each filtered as note (note.id)} + {#if editingId === note.id} + +
+ + +
+ + +
+
+ {:else} + + + {/if} + {/each} +
+ + {#if notes.length === 0 && !showCreate} +
+

Noch keine Notizen.

+ +
+ {/if} +
+ + diff --git a/apps/manacore/apps/web/src/lib/modules/notes/collections.ts b/apps/manacore/apps/web/src/lib/modules/notes/collections.ts new file mode 100644 index 000000000..b4709dbd5 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/notes/collections.ts @@ -0,0 +1,34 @@ +/** + * Notes module — collection accessors and guest seed data. + */ + +import { db } from '$lib/data/database'; +import type { LocalNote } from './types'; + +// ─── Collection Accessors ────────────────────────────────── + +export const noteTable = db.table('notes'); + +// ─── Guest Seed ──────────────────────────────────────────── + +export const NOTES_GUEST_SEED = { + notes: [ + { + id: 'note-welcome', + title: 'Willkommen bei Notes', + content: + 'Schnelle Notizen für alles, was dir einfällt.\n\nDu kannst Notizen **pinnen**, farblich markieren und durchsuchen.', + color: '#3b82f6', + isPinned: true, + isArchived: false, + }, + { + id: 'note-ideas', + title: 'Ideen', + content: '- Feature X ausprobieren\n- Blog-Post schreiben\n- Design Review planen', + color: '#f59e0b', + isPinned: false, + isArchived: false, + }, + ] satisfies LocalNote[], +}; diff --git a/apps/manacore/apps/web/src/lib/modules/notes/entity.ts b/apps/manacore/apps/web/src/lib/modules/notes/entity.ts new file mode 100644 index 000000000..bfe4ab562 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/notes/entity.ts @@ -0,0 +1,38 @@ +import { registerEntity } from '$lib/entities/registry'; +import { notesStore } from './stores/notes.svelte'; +import type { EntityDescriptor } from '$lib/entities/types'; + +const notesEntity: EntityDescriptor = { + appId: 'notes', + collection: 'notes', + paramKey: 'noteId', + + getDisplayData: (item) => ({ + title: (item.title as string) || 'Notiz', + subtitle: undefined, + }), + + dragType: 'note', + acceptsDropFrom: ['task', 'contact'], + + transformIncoming: { + task: (source) => ({ + title: source.title as string, + content: (source.description as string) ?? '', + }), + contact: (source) => ({ + title: `${[source.firstName, source.lastName].filter(Boolean).join(' ')}`, + content: `Kontakt: ${[source.firstName, source.lastName].filter(Boolean).join(' ')}`, + }), + }, + + createItem: async (data) => { + const note = await notesStore.createNote({ + title: data.title as string, + content: (data.content as string) ?? '', + }); + return note.id; + }, +}; + +registerEntity(notesEntity); diff --git a/apps/manacore/apps/web/src/lib/modules/notes/index.ts b/apps/manacore/apps/web/src/lib/modules/notes/index.ts new file mode 100644 index 000000000..ae79ee0f2 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/notes/index.ts @@ -0,0 +1,16 @@ +/** + * Notes module — barrel exports. + */ + +// ─── Stores ────────────────────────────────────────────── +export { notesStore } from './stores/notes.svelte'; + +// ─── Queries ───────────────────────────────────────────── +export { useAllNotes, toNote, searchNotes, getPreview, formatRelativeTime } from './queries'; + +// ─── Collections ───────────────────────────────────────── +export { noteTable, NOTES_GUEST_SEED } from './collections'; + +// ─── Types ─────────────────────────────────────────────── +export { NOTE_COLORS } from './types'; +export type { LocalNote, Note } from './types'; diff --git a/apps/manacore/apps/web/src/lib/modules/notes/queries.ts b/apps/manacore/apps/web/src/lib/modules/notes/queries.ts new file mode 100644 index 000000000..afeafdb99 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/notes/queries.ts @@ -0,0 +1,68 @@ +/** + * Reactive Queries & Pure Helpers for Notes module. + */ + +import { liveQuery } from 'dexie'; +import { db } from '$lib/data/database'; +import type { LocalNote, Note } from './types'; + +// ─── Type Converters ─────────────────────────────────────── + +export function toNote(local: LocalNote): Note { + return { + id: local.id, + title: local.title, + content: local.content, + color: local.color, + isPinned: local.isPinned, + isArchived: local.isArchived, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +// ─── Live Queries ────────────────────────────────────────── + +export function useAllNotes() { + return liveQuery(async () => { + const locals = await db.table('notes').toArray(); + return locals + .filter((n) => !n.deletedAt && !n.isArchived) + .map(toNote) + .sort((a, b) => { + if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1; + return b.updatedAt.localeCompare(a.updatedAt); + }); + }); +} + +// ─── Pure Helpers ────────────────────────────────────────── + +/** Search notes by title and content */ +export function searchNotes(notes: Note[], query: string): Note[] { + if (!query.trim()) return notes; + const q = query.toLowerCase(); + return notes.filter( + (n) => n.title.toLowerCase().includes(q) || n.content.toLowerCase().includes(q) + ); +} + +/** Get content preview (first line or truncated) */ +export function getPreview(content: string, maxLen = 80): string { + const firstLine = content.split('\n').find((l) => l.trim()) ?? ''; + const clean = firstLine.replace(/[#*_~`>\-]/g, '').trim(); + return clean.length > maxLen ? clean.slice(0, maxLen) + '...' : clean; +} + +/** Format relative time */ +export function formatRelativeTime(iso: string): string { + const diff = Date.now() - new Date(iso).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return 'gerade eben'; + if (mins < 60) return `vor ${mins}m`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `vor ${hours}h`; + const days = Math.floor(hours / 24); + if (days < 7) return `vor ${days}d`; + return new Date(iso).toLocaleDateString('de-DE', { day: 'numeric', month: 'short' }); +} diff --git a/apps/manacore/apps/web/src/lib/modules/notes/stores/notes.svelte.ts b/apps/manacore/apps/web/src/lib/modules/notes/stores/notes.svelte.ts new file mode 100644 index 000000000..3ba2dfe5d --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/notes/stores/notes.svelte.ts @@ -0,0 +1,56 @@ +/** + * Notes Store — Mutation-Only Service + */ + +import { noteTable } from '../collections'; +import { toNote } from '../queries'; +import type { LocalNote } from '../types'; + +export const notesStore = { + async createNote(data: { title?: string; content?: string; color?: string | null }) { + const newLocal: LocalNote = { + id: crypto.randomUUID(), + title: data.title ?? '', + content: data.content ?? '', + color: data.color ?? null, + isPinned: false, + isArchived: false, + }; + + await noteTable.add(newLocal); + return toNote(newLocal); + }, + + async updateNote( + id: string, + data: Partial> + ) { + await noteTable.update(id, { + ...data, + updatedAt: new Date().toISOString(), + }); + }, + + async deleteNote(id: string) { + await noteTable.update(id, { + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + }, + + async togglePin(id: string) { + const note = await noteTable.get(id); + if (!note) return; + await noteTable.update(id, { + isPinned: !note.isPinned, + updatedAt: new Date().toISOString(), + }); + }, + + async archiveNote(id: string) { + await noteTable.update(id, { + isArchived: true, + updatedAt: new Date().toISOString(), + }); + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/notes/types.ts b/apps/manacore/apps/web/src/lib/modules/notes/types.ts new file mode 100644 index 000000000..8ecc3f276 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/notes/types.ts @@ -0,0 +1,46 @@ +/** + * Notes module types. + * + * Lightweight markdown notes — flat structure, no folders. + */ + +import type { BaseRecord } from '@manacore/local-store'; + +// ─── Local Record Types (Dexie) ─────────────────────────── + +export interface LocalNote extends BaseRecord { + title: string; + content: string; + color: string | null; + isPinned: boolean; + isArchived: boolean; +} + +// ─── Domain Types ───────────────────────────────────────── + +export interface Note { + id: string; + title: string; + content: string; + color: string | null; + isPinned: boolean; + isArchived: boolean; + createdAt: string; + updatedAt: string; +} + +// ─── Constants ──────────────────────────────────────────── + +export const NOTE_COLORS: (string | null)[] = [ + null, + '#ef4444', + '#f97316', + '#f59e0b', + '#84cc16', + '#22c55e', + '#06b6d4', + '#3b82f6', + '#6366f1', + '#8b5cf6', + '#ec4899', +]; diff --git a/apps/manacore/apps/web/src/routes/(app)/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/+page.svelte index 1d7f96216..0e8baa0a9 100644 --- a/apps/manacore/apps/web/src/routes/(app)/+page.svelte +++ b/apps/manacore/apps/web/src/routes/(app)/+page.svelte @@ -2,24 +2,20 @@ import AppPage from '$lib/components/workbench/AppPage.svelte'; import AppPagePicker from '$lib/components/workbench/AppPagePicker.svelte'; import { PageCarousel, type CarouselPage } from '$lib/components/page-carousel'; - import { getAppEntry } from '$lib/components/workbench/app-registry'; + import { getApp, getAppByDragType } from '$lib/app-registry'; import { createAppSettingsStore } from '@manacore/shared-stores'; import { DragPreview } from '@manacore/shared-ui/dnd'; - import { getEntityByDragType, ensureEntitiesRegistered } from '$lib/entities'; import type { DragType } from '@manacore/shared-ui/dnd'; - ensureEntitiesRegistered(); - function resolveEntity(type: string, data: Record) { - const entity = getEntityByDragType(type as DragType); - if (!entity) return null; - const display = entity.getDisplayData(data); - const appEntry = getAppEntry(entity.appId); + const app = getAppByDragType(type as DragType); + if (!app?.getDisplayData) return null; + const display = app.getDisplayData(data); return { title: display.title, subtitle: display.subtitle, - color: appEntry?.color, - appName: appEntry?.name, + color: app.color, + appName: app.name, }; } @@ -42,6 +38,8 @@ { appId: 'calendar', minimized: false }, { appId: 'contacts', minimized: false }, { appId: 'habits', minimized: false }, + { appId: 'notes', minimized: false }, + { appId: 'finance', minimized: false }, ], }); @@ -58,6 +56,8 @@ { appId: 'calendar', minimized: false }, { appId: 'contacts', minimized: false }, { appId: 'habits', minimized: false }, + { appId: 'notes', minimized: false }, + { appId: 'finance', minimized: false }, ]); // Load persisted state once on mount (not reactive — avoids loop with persistState) @@ -82,7 +82,7 @@ // ── Map to CarouselPage[] ─────────────────────────────── let carouselPages = $derived( openApps.map((a) => { - const entry = getAppEntry(a.appId); + const entry = getApp(a.appId); return { id: a.appId, minimized: a.minimized, diff --git a/apps/manacore/apps/web/src/routes/(app)/finance/+layout.svelte b/apps/manacore/apps/web/src/routes/(app)/finance/+layout.svelte new file mode 100644 index 000000000..1fc53bab2 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/finance/+layout.svelte @@ -0,0 +1,15 @@ + + +{@render children()} diff --git a/apps/manacore/apps/web/src/routes/(app)/finance/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/finance/+page.svelte new file mode 100644 index 000000000..5a9fd8baa --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/finance/+page.svelte @@ -0,0 +1,564 @@ + + + + Finance - ManaCore + + +
+ + + +
+ + {monthLabel} + +
+ + {#if isLoaded} + +
+
+ Einnahmen + +{formatCurrency(income)} +
+
+ Ausgaben + -{formatCurrency(expenses)} +
+
+ Bilanz + = 0} class:expense={balance < 0}> + {balance >= 0 ? '+' : ''}{formatCurrency(balance)} + +
+
+ + + {#if spending.size > 0} +
+

Ausgaben nach Kategorie

+
+ {#each expenseCategories as cat (cat.id)} + {@const amount = spending.get(cat.id) ?? 0} + {#if amount > 0} +
+ {cat.emoji} + {cat.name} +
+
+
+ {formatCurrency(amount)} +
+ {/if} + {/each} +
+
+ {/if} + + + + + + {#if showAdd} +
+
+ + +
+
+ + \u20ac +
+ +
+ {#each filteredCats as cat (cat.id)} + + {/each} +
+ +
+ {/if} + + + {#if monthTxs.length > 0} +
+

Transaktionen

+
+ {#each [...grouped.entries()] as [date, dayTxs] (date)} +
{formatDateLabel(date)}
+ {#each dayTxs as tx (tx.id)} + {@const cat = tx.categoryId ? catMap.get(tx.categoryId) : null} +
+ {cat?.emoji ?? '\ud83d\udcb3'} +
+ {tx.description} + {#if cat}{cat.name}{/if} +
+ + {tx.type === 'income' ? '+' : '-'}{formatCurrency(tx.amount)} + +
+ {/each} + {/each} +
+
+ {/if} + {:else} +
Laden...
+ {/if} +
+ + diff --git a/apps/manacore/apps/web/src/routes/(app)/notes/+layout.svelte b/apps/manacore/apps/web/src/routes/(app)/notes/+layout.svelte new file mode 100644 index 000000000..b7d430ec9 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/notes/+layout.svelte @@ -0,0 +1,12 @@ + + +{@render children()} diff --git a/apps/manacore/apps/web/src/routes/(app)/notes/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/notes/+page.svelte new file mode 100644 index 000000000..cc0598bdb --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/notes/+page.svelte @@ -0,0 +1,403 @@ + + + + Notes - ManaCore + + +
+
+
+

Notes

+ {#if isLoaded} +
{notes.length} Notizen
+ {/if} +
+
+ + +
+ + +
+ + + {#if showCreate} +
+ + + +
+ {/if} + + {#if isLoaded} + + {#if pinnedNotes.length > 0} +
+ + +
+ {/if} + + + {#if unpinnedNotes.length > 0} +
+ {#if pinnedNotes.length > 0} + + {/if} + +
+ {/if} + + {#if notes.length === 0 && !showCreate} +
+

Noch keine Notizen.

+ +
+ {/if} + {:else} +
Laden...
+ {/if} +
+ + diff --git a/apps/manacore/apps/web/src/routes/(app)/notes/[id]/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/notes/[id]/+page.svelte new file mode 100644 index 000000000..aec98b9b8 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/notes/[id]/+page.svelte @@ -0,0 +1,314 @@ + + + + {note ? note.title || 'Notiz' : 'Notiz'} - ManaCore + + +
+ {#if note} +
+ +
{formatRelativeTime(note.updatedAt)}
+
+ +
+
+ + + + + + + + {:else if notes.length > 0} +
+

Notiz nicht gefunden.

+ +
+ {:else} +
Laden...
+ {/if} +
+ + diff --git a/packages/shared-branding/src/app-icons.ts b/packages/shared-branding/src/app-icons.ts index 00a7bd5e5..582d16f6a 100644 --- a/packages/shared-branding/src/app-icons.ts +++ b/packages/shared-branding/src/app-icons.ts @@ -134,6 +134,12 @@ export const APP_ICONS = { habits: svgToDataUrl( `` ), + notes: svgToDataUrl( + `` + ), + finance: svgToDataUrl( + `` + ), arcade: svgToDataUrl( `` ), diff --git a/packages/shared-branding/src/mana-apps.ts b/packages/shared-branding/src/mana-apps.ts index 6ef92c82a..2bb1745a9 100644 --- a/packages/shared-branding/src/mana-apps.ts +++ b/packages/shared-branding/src/mana-apps.ts @@ -598,6 +598,40 @@ export const MANA_APPS: ManaApp[] = [ status: 'development', requiredTier: 'founder', }, + { + id: 'notes', + name: 'Notes', + description: { + de: 'Schnelle Notizen', + en: 'Quick Notes', + }, + longDescription: { + de: 'Leichtgewichtige Notizen mit Suche, Farbmarkierungen und Pin-Funktion. Kein Overhead, sofort losschreiben.', + en: 'Lightweight notes with search, color tags, and pinning. No overhead, start writing immediately.', + }, + icon: APP_ICONS.notes, + color: '#f59e0b', + comingSoon: false, + status: 'development', + requiredTier: 'founder', + }, + { + id: 'finance', + name: 'Finance', + description: { + de: 'Einnahmen & Ausgaben', + en: 'Income & Expenses', + }, + longDescription: { + de: 'Einfaches Finanztracking mit Kategorien, Monatsbudgets und Übersicht deiner Einnahmen und Ausgaben.', + en: 'Simple finance tracking with categories, monthly budgets, and overview of your income and expenses.', + }, + icon: APP_ICONS.finance, + color: '#22c55e', + comingSoon: false, + status: 'development', + requiredTier: 'founder', + }, { id: 'arcade', name: 'Arcade', @@ -727,6 +761,8 @@ export const APP_URLS: Record = { memoro: { dev: 'http://localhost:5173/memoro', prod: 'https://mana.how/memoro' }, guides: { dev: 'http://localhost:5173/guides', prod: 'https://mana.how/guides' }, habits: { dev: 'http://localhost:5173/habits', prod: 'https://mana.how/habits' }, + notes: { dev: 'http://localhost:5173/notes', prod: 'https://mana.how/notes' }, + finance: { dev: 'http://localhost:5173/finance', prod: 'https://mana.how/finance' }, wisekeep: { dev: 'http://localhost:5173/wisekeep', prod: 'https://mana.how/wisekeep' }, news: { dev: 'http://localhost:5173/news', prod: 'https://mana.how/news' }, mail: { dev: 'http://localhost:5173/mail', prod: 'https://mana.how/mail' }, diff --git a/packages/shared-ui/src/dnd/types.ts b/packages/shared-ui/src/dnd/types.ts index 1571e4557..926156bce 100644 --- a/packages/shared-ui/src/dnd/types.ts +++ b/packages/shared-ui/src/dnd/types.ts @@ -20,7 +20,9 @@ export type DragType = | 'event' | 'link' | 'contact' - | 'habit'; + | 'habit' + | 'note' + | 'transaction'; export interface DragPayload> { type: DragType;