refactor(go-services): integrate shared-go into crawler + gateway, fix Dockerfiles

- mana-crawler: config → envutil, handler → httputil.WriteJSON
- mana-api-gateway: config → envutil, handlers → httputil.WriteJSON
- Fix Dockerfile COPY paths (remove stale -go suffix in all 4 services)
- All services now use packages/shared-go via replace directive

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-28 16:25:30 +01:00
parent ba6dbf16c4
commit bf4d9cb9aa
39 changed files with 1313 additions and 1379 deletions

View file

@ -0,0 +1,288 @@
/**
* Reactive Queries & Pure Helpers for Inventar
*
* Uses Dexie liveQuery to automatically re-render when IndexedDB changes
* (local writes, sync updates, other tabs). Components call these hooks
* at init time; no manual fetch/refresh needed.
*/
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import {
collectionCollection,
itemCollection,
locationCollection,
categoryCollection,
type LocalCollection,
type LocalItem,
type LocalLocation,
type LocalCategory,
} from './local-store';
import type {
Collection,
Item,
Location,
Category,
ItemStatus,
SortOption,
FilterCriteria,
} from '@inventar/shared';
// ─── Type Converters ───────────────────────────────────────
/** Convert a LocalCollection (IndexedDB) to the shared Collection type. */
export function toCollection(local: LocalCollection): Collection {
return {
id: local.id,
name: local.name,
description: local.description ?? undefined,
icon: local.icon ?? undefined,
color: local.color ?? undefined,
schema: local.schema,
templateId: local.templateId ?? undefined,
order: local.order,
itemCount: local.itemCount,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
/** Convert a LocalItem (IndexedDB) to the shared Item type. */
export function toItem(local: LocalItem): Item {
return {
id: local.id,
collectionId: local.collectionId,
locationId: local.locationId ?? undefined,
categoryId: local.categoryId ?? undefined,
name: local.name,
description: local.description ?? undefined,
status: local.status,
quantity: local.quantity,
fieldValues: local.fieldValues,
purchaseData: local.purchaseData ?? undefined,
photos: local.photos,
notes: local.notes,
documents: [],
tags: local.tags,
order: local.order,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
/** Convert a LocalLocation (IndexedDB) to the shared Location type. */
export function toLocation(local: LocalLocation): Location {
return {
id: local.id,
parentId: local.parentId ?? undefined,
name: local.name,
description: local.description ?? undefined,
icon: local.icon ?? undefined,
path: local.path,
depth: local.depth,
order: local.order,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
/** Convert a LocalCategory (IndexedDB) to the shared Category type. */
export function toCategory(local: LocalCategory): Category {
return {
id: local.id,
parentId: local.parentId ?? undefined,
name: local.name,
icon: local.icon ?? undefined,
color: local.color ?? undefined,
order: local.order,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
// ─── Live Query Hooks (call during component init) ─────────
/** All collections. Auto-updates on any change. */
export function useAllCollections() {
return useLiveQueryWithDefault(async () => {
const locals = await collectionCollection.getAll();
return locals.map(toCollection);
}, [] as Collection[]);
}
/** All items. Auto-updates on any change. */
export function useAllItems() {
return useLiveQueryWithDefault(async () => {
const locals = await itemCollection.getAll();
return locals.map(toItem);
}, [] as Item[]);
}
/** All locations. Auto-updates on any change. */
export function useAllLocations() {
return useLiveQueryWithDefault(async () => {
const locals = await locationCollection.getAll();
return locals.map(toLocation);
}, [] as Location[]);
}
/** All categories. Auto-updates on any change. */
export function useAllCategories() {
return useLiveQueryWithDefault(async () => {
const locals = await categoryCollection.getAll();
return locals.map(toCategory);
}, [] as Category[]);
}
// ─── Pure Collection Helpers ──────────────────────────────
/** Get a collection by ID. */
export function getCollectionById(collections: Collection[], id: string): Collection | undefined {
return collections.find((c) => c.id === id);
}
/** Get collections sorted by order. */
export function getSortedCollections(collections: Collection[]): Collection[] {
return [...collections].sort((a, b) => a.order - b.order);
}
// ─── Pure Item Helpers ────────────────────────────────────
/** Get an item by ID. */
export function getItemById(items: Item[], id: string): Item | undefined {
return items.find((i) => i.id === id);
}
/** Get items for a specific collection. */
export function getItemsByCollection(items: Item[], collectionId: string): Item[] {
return items.filter((i) => i.collectionId === collectionId);
}
/** Count items for a specific collection. */
export function getItemCountByCollection(items: Item[], collectionId: string): number {
return items.filter((i) => i.collectionId === collectionId).length;
}
/** Get total item count. */
export function getTotalItemCount(items: Item[]): number {
return items.length;
}
/** Filter items by criteria. */
export function getFilteredItems(items: Item[], filters: FilterCriteria): Item[] {
let result = items;
if (filters.collectionId) {
result = result.filter((i) => i.collectionId === filters.collectionId);
}
if (filters.locationId) {
result = result.filter((i) => i.locationId === filters.locationId);
}
if (filters.categoryId) {
result = result.filter((i) => i.categoryId === filters.categoryId);
}
if (filters.status?.length) {
result = result.filter((i) => filters.status!.includes(i.status));
}
if (filters.tagIds?.length) {
result = result.filter((i) => filters.tagIds!.some((t) => i.tags.includes(t)));
}
if (filters.search) {
const q = filters.search.toLowerCase();
result = result.filter(
(i) =>
i.name.toLowerCase().includes(q) ||
i.description?.toLowerCase().includes(q) ||
Object.values(i.fieldValues).some((v) => String(v).toLowerCase().includes(q))
);
}
return result;
}
/** Sort items by a sort option. */
export function getSortedItems(itemList: Item[], sort: SortOption): Item[] {
return [...itemList].sort((a, b) => {
let cmp = 0;
switch (sort.field) {
case 'name':
cmp = a.name.localeCompare(b.name);
break;
case 'createdAt':
cmp = a.createdAt.localeCompare(b.createdAt);
break;
case 'updatedAt':
cmp = a.updatedAt.localeCompare(b.updatedAt);
break;
case 'status':
cmp = a.status.localeCompare(b.status);
break;
case 'quantity':
cmp = a.quantity - b.quantity;
break;
}
return sort.direction === 'desc' ? -cmp : cmp;
});
}
// ─── Pure Location Helpers ────────────────────────────────
/** Get a location by ID. */
export function getLocationById(locations: Location[], id: string): Location | undefined {
return locations.find((l) => l.id === id);
}
/** Get root locations (no parent). */
export function getRootLocations(locations: Location[]): Location[] {
return locations.filter((l) => !l.parentId).sort((a, b) => a.order - b.order);
}
/** Get children of a location. */
export function getLocationChildren(locations: Location[], parentId: string): Location[] {
return locations.filter((l) => l.parentId === parentId).sort((a, b) => a.order - b.order);
}
/** Build a tree structure from flat locations. */
export function getLocationTree(locations: Location[]): Location[] {
const buildTree = (parentId?: string): Location[] => {
return locations
.filter((l) => l.parentId === parentId)
.sort((a, b) => a.order - b.order)
.map((l) => ({ ...l, children: buildTree(l.id) }));
};
return buildTree(undefined);
}
/** Get full path for a location. */
export function getLocationFullPath(locations: Location[], id: string): string {
const location = locations.find((l) => l.id === id);
if (!location) return '';
return location.path ? `${location.path}/${location.name}` : location.name;
}
// ─── Pure Category Helpers ────────────────────────────────
/** Get a category by ID. */
export function getCategoryById(categories: Category[], id: string): Category | undefined {
return categories.find((c) => c.id === id);
}
/** Get root categories (no parent). */
export function getRootCategories(categories: Category[]): Category[] {
return categories.filter((c) => !c.parentId).sort((a, b) => a.order - b.order);
}
/** Get children of a category. */
export function getCategoryChildren(categories: Category[], parentId: string): Category[] {
return categories.filter((c) => c.parentId === parentId).sort((a, b) => a.order - b.order);
}
/** Build a tree structure from flat categories. */
export function getCategoryTree(categories: Category[]): Category[] {
const buildTree = (parentId?: string): Category[] => {
return categories
.filter((c) => c.parentId === parentId)
.sort((a, b) => a.order - b.order)
.map((c) => ({ ...c, children: buildTree(c.id) }));
};
return buildTree(undefined);
}

View file

@ -1,100 +1,44 @@
import { browser } from '$app/environment';
import type { Category } from '@inventar/shared';
/**
* Categories Store Mutations Only
*
* Reads come from useLiveQuery (see $lib/data/queries.ts).
* This store only handles writes to IndexedDB via local-store.
*/
const STORAGE_KEY = 'inventar_categories';
function loadFromStorage(): Category[] {
if (!browser) return [];
try {
const data = localStorage.getItem(STORAGE_KEY);
return data ? JSON.parse(data) : [];
} catch {
return [];
}
}
function saveToStorage(categories: Category[]) {
if (!browser) return;
localStorage.setItem(STORAGE_KEY, JSON.stringify(categories));
}
function generateId(): string {
return crypto.randomUUID();
}
let categories = $state<Category[]>([]);
let initialized = $state(false);
import { categoryCollection, type LocalCategory } from '$lib/data/local-store';
import { toCategory } from '$lib/data/queries';
export const categoriesStore = {
get categories() {
return categories;
},
get initialized() {
return initialized;
},
async create(data: { name: string; icon?: string; color?: string; parentId?: string }) {
const all = await categoryCollection.getAll();
const siblings = all.filter((c) => c.parentId === data.parentId);
initialize() {
if (initialized) return;
categories = loadFromStorage();
initialized = true;
},
getById(id: string): Category | undefined {
return categories.find((c) => c.id === id);
},
getRootCategories(): Category[] {
return categories.filter((c) => !c.parentId).sort((a, b) => a.order - b.order);
},
getChildren(parentId: string): Category[] {
return categories.filter((c) => c.parentId === parentId).sort((a, b) => a.order - b.order);
},
getTree(): Category[] {
const buildTree = (parentId?: string): Category[] => {
return categories
.filter((c) => c.parentId === parentId)
.sort((a, b) => a.order - b.order)
.map((c) => ({ ...c, children: buildTree(c.id) }));
};
return buildTree(undefined);
},
create(data: { name: string; icon?: string; color?: string; parentId?: string }): Category {
const now = new Date().toISOString();
const siblings = categories.filter((c) => c.parentId === data.parentId);
const category: Category = {
id: generateId(),
parentId: data.parentId,
const newLocal: LocalCategory = {
id: crypto.randomUUID(),
parentId: data.parentId ?? null,
name: data.name,
icon: data.icon,
color: data.color,
icon: data.icon ?? null,
color: data.color ?? null,
order: siblings.length,
createdAt: now,
updatedAt: now,
};
categories = [...categories, category];
saveToStorage(categories);
return category;
const inserted = await categoryCollection.insert(newLocal);
return toCategory(inserted);
},
update(id: string, data: Partial<Pick<Category, 'name' | 'icon' | 'color'>>) {
categories = categories.map((c) =>
c.id === id ? { ...c, ...data, updatedAt: new Date().toISOString() } : c
);
saveToStorage(categories);
async update(id: string, data: Partial<Pick<LocalCategory, 'name' | 'icon' | 'color'>>) {
await categoryCollection.update(id, data);
},
delete(id: string) {
async delete(id: string) {
const all = await categoryCollection.getAll();
const idsToDelete = new Set<string>();
const collectIds = (parentId: string) => {
idsToDelete.add(parentId);
categories.filter((c) => c.parentId === parentId).forEach((c) => collectIds(c.id));
all.filter((c) => c.parentId === parentId).forEach((c) => collectIds(c.id));
};
collectIds(id);
categories = categories.filter((c) => !idsToDelete.has(c.id));
saveToStorage(categories);
for (const deleteId of idsToDelete) {
await categoryCollection.delete(deleteId);
}
},
};

View file

@ -1,102 +1,57 @@
import { browser } from '$app/environment';
import type { Collection, CollectionSchema } from '@inventar/shared';
/**
* Collections Store Mutations Only
*
* Reads come from useLiveQuery (see $lib/data/queries.ts).
* This store only handles writes to IndexedDB via local-store.
*/
const STORAGE_KEY = 'inventar_collections';
function loadFromStorage(): Collection[] {
if (!browser) return [];
try {
const data = localStorage.getItem(STORAGE_KEY);
return data ? JSON.parse(data) : [];
} catch {
return [];
}
}
function saveToStorage(collections: Collection[]) {
if (!browser) return;
localStorage.setItem(STORAGE_KEY, JSON.stringify(collections));
}
function generateId(): string {
return crypto.randomUUID();
}
let collections = $state<Collection[]>([]);
let initialized = $state(false);
import type { CollectionSchema } from '@inventar/shared';
import { collectionCollection, type LocalCollection } from '$lib/data/local-store';
import { toCollection } from '$lib/data/queries';
export const collectionsStore = {
get collections() {
return collections;
},
get initialized() {
return initialized;
},
initialize() {
if (initialized) return;
collections = loadFromStorage();
initialized = true;
},
getById(id: string): Collection | undefined {
return collections.find((c) => c.id === id);
},
create(data: {
async create(data: {
name: string;
description?: string;
icon?: string;
color?: string;
schema: CollectionSchema;
templateId?: string;
}): Collection {
const now = new Date().toISOString();
const collection: Collection = {
id: generateId(),
}) {
const all = await collectionCollection.getAll();
const newLocal: LocalCollection = {
id: crypto.randomUUID(),
name: data.name,
description: data.description,
icon: data.icon,
color: data.color,
description: data.description ?? null,
icon: data.icon ?? null,
color: data.color ?? null,
schema: data.schema,
templateId: data.templateId,
order: collections.length,
templateId: data.templateId ?? null,
order: all.length,
itemCount: 0,
createdAt: now,
updatedAt: now,
};
collections = [...collections, collection];
saveToStorage(collections);
return collection;
const inserted = await collectionCollection.insert(newLocal);
return toCollection(inserted);
},
update(
async update(
id: string,
data: Partial<Pick<Collection, 'name' | 'description' | 'icon' | 'color' | 'schema'>>
data: Partial<Pick<LocalCollection, 'name' | 'description' | 'icon' | 'color' | 'schema'>>
) {
collections = collections.map((c) =>
c.id === id ? { ...c, ...data, updatedAt: new Date().toISOString() } : c
);
saveToStorage(collections);
await collectionCollection.update(id, data);
},
delete(id: string) {
collections = collections.filter((c) => c.id !== id);
saveToStorage(collections);
async delete(id: string) {
await collectionCollection.delete(id);
},
reorder(orderedIds: string[]) {
collections = orderedIds
.map((id, index) => {
const c = collections.find((col) => col.id === id);
return c ? { ...c, order: index } : null;
})
.filter((c): c is Collection => c !== null);
saveToStorage(collections);
async reorder(orderedIds: string[]) {
for (let i = 0; i < orderedIds.length; i++) {
await collectionCollection.update(orderedIds[i], { order: i });
}
},
updateItemCount(collectionId: string, count: number) {
collections = collections.map((c) => (c.id === collectionId ? { ...c, itemCount: count } : c));
saveToStorage(collections);
async updateItemCount(collectionId: string, count: number) {
await collectionCollection.update(collectionId, { itemCount: count });
},
};

View file

@ -1,122 +1,16 @@
import { browser } from '$app/environment';
import type {
Item,
ItemStatus,
ItemNote,
ItemPhoto,
PurchaseData,
SortOption,
} from '@inventar/shared';
/**
* Items Store Mutations Only
*
* Reads come from useLiveQuery (see $lib/data/queries.ts).
* This store only handles writes to IndexedDB via local-store.
*/
const STORAGE_KEY = 'inventar_items';
function loadFromStorage(): Item[] {
if (!browser) return [];
try {
const data = localStorage.getItem(STORAGE_KEY);
return data ? JSON.parse(data) : [];
} catch {
return [];
}
}
function saveToStorage(items: Item[]) {
if (!browser) return;
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
}
function generateId(): string {
return crypto.randomUUID();
}
let items = $state<Item[]>([]);
let initialized = $state(false);
import type { ItemStatus, PurchaseData, ItemPhoto, ItemNote } from '@inventar/shared';
import { itemCollection, type LocalItem } from '$lib/data/local-store';
import { toItem } from '$lib/data/queries';
export const itemsStore = {
get items() {
return items;
},
get initialized() {
return initialized;
},
initialize() {
if (initialized) return;
items = loadFromStorage();
initialized = true;
},
getById(id: string): Item | undefined {
return items.find((i) => i.id === id);
},
getByCollection(collectionId: string): Item[] {
return items.filter((i) => i.collectionId === collectionId);
},
getFiltered(filters: {
collectionId?: string;
locationId?: string;
categoryId?: string;
status?: ItemStatus[];
search?: string;
tagIds?: string[];
}): Item[] {
let result = items;
if (filters.collectionId) {
result = result.filter((i) => i.collectionId === filters.collectionId);
}
if (filters.locationId) {
result = result.filter((i) => i.locationId === filters.locationId);
}
if (filters.categoryId) {
result = result.filter((i) => i.categoryId === filters.categoryId);
}
if (filters.status?.length) {
result = result.filter((i) => filters.status!.includes(i.status));
}
if (filters.tagIds?.length) {
result = result.filter((i) => filters.tagIds!.some((t) => i.tags.includes(t)));
}
if (filters.search) {
const q = filters.search.toLowerCase();
result = result.filter(
(i) =>
i.name.toLowerCase().includes(q) ||
i.description?.toLowerCase().includes(q) ||
Object.values(i.fieldValues).some((v) => String(v).toLowerCase().includes(q))
);
}
return result;
},
getSorted(itemList: Item[], sort: SortOption): Item[] {
return [...itemList].sort((a, b) => {
let cmp = 0;
switch (sort.field) {
case 'name':
cmp = a.name.localeCompare(b.name);
break;
case 'createdAt':
cmp = a.createdAt.localeCompare(b.createdAt);
break;
case 'updatedAt':
cmp = a.updatedAt.localeCompare(b.updatedAt);
break;
case 'status':
cmp = a.status.localeCompare(b.status);
break;
case 'quantity':
cmp = a.quantity - b.quantity;
break;
}
return sort.direction === 'desc' ? -cmp : cmp;
});
},
create(data: {
async create(data: {
collectionId: string;
name: string;
description?: string;
@ -127,37 +21,35 @@ export const itemsStore = {
fieldValues?: Record<string, unknown>;
purchaseData?: PurchaseData;
tags?: string[];
}): Item {
const now = new Date().toISOString();
const item: Item = {
id: generateId(),
}) {
const existing = await itemCollection.getAll();
const collectionItems = existing.filter((i) => i.collectionId === data.collectionId);
const newLocal: LocalItem = {
id: crypto.randomUUID(),
collectionId: data.collectionId,
name: data.name,
description: data.description,
description: data.description ?? null,
status: data.status || 'owned',
quantity: data.quantity || 1,
locationId: data.locationId,
categoryId: data.categoryId,
locationId: data.locationId ?? null,
categoryId: data.categoryId ?? null,
fieldValues: data.fieldValues || {},
purchaseData: data.purchaseData,
purchaseData: data.purchaseData ?? null,
photos: [],
notes: [],
documents: [],
tags: data.tags || [],
order: items.filter((i) => i.collectionId === data.collectionId).length,
createdAt: now,
updatedAt: now,
order: collectionItems.length,
};
items = [...items, item];
saveToStorage(items);
return item;
const inserted = await itemCollection.insert(newLocal);
return toItem(inserted);
},
update(
async update(
id: string,
data: Partial<
Pick<
Item,
LocalItem,
| 'name'
| 'description'
| 'status'
@ -170,91 +62,62 @@ export const itemsStore = {
>
>
) {
items = items.map((i) =>
i.id === id ? { ...i, ...data, updatedAt: new Date().toISOString() } : i
);
saveToStorage(items);
await itemCollection.update(id, data);
},
delete(id: string) {
items = items.filter((i) => i.id !== id);
saveToStorage(items);
async delete(id: string) {
await itemCollection.delete(id);
},
deleteByCollection(collectionId: string) {
items = items.filter((i) => i.collectionId !== collectionId);
saveToStorage(items);
async deleteByCollection(collectionId: string) {
const all = await itemCollection.getAll();
const toDelete = all.filter((i) => i.collectionId === collectionId);
for (const item of toDelete) {
await itemCollection.delete(item.id);
}
},
addNote(itemId: string, content: string) {
async addNote(itemId: string, content: string) {
const item = await itemCollection.get(itemId);
if (!item) return;
const now = new Date().toISOString();
const note: ItemNote = { id: generateId(), content, createdAt: now, updatedAt: now };
items = items.map((i) =>
i.id === itemId ? { ...i, notes: [...i.notes, note], updatedAt: now } : i
);
saveToStorage(items);
const note: ItemNote = { id: crypto.randomUUID(), content, createdAt: now, updatedAt: now };
await itemCollection.update(itemId, {
notes: [...item.notes, note],
});
},
updateNote(itemId: string, noteId: string, content: string) {
async updateNote(itemId: string, noteId: string, content: string) {
const item = await itemCollection.get(itemId);
if (!item) return;
const now = new Date().toISOString();
items = items.map((i) =>
i.id === itemId
? {
...i,
notes: i.notes.map((n) => (n.id === noteId ? { ...n, content, updatedAt: now } : n)),
updatedAt: now,
}
: i
);
saveToStorage(items);
await itemCollection.update(itemId, {
notes: item.notes.map((n) => (n.id === noteId ? { ...n, content, updatedAt: now } : n)),
});
},
deleteNote(itemId: string, noteId: string) {
items = items.map((i) =>
i.id === itemId
? {
...i,
notes: i.notes.filter((n) => n.id !== noteId),
updatedAt: new Date().toISOString(),
}
: i
);
saveToStorage(items);
async deleteNote(itemId: string, noteId: string) {
const item = await itemCollection.get(itemId);
if (!item) return;
await itemCollection.update(itemId, {
notes: item.notes.filter((n) => n.id !== noteId),
});
},
addPhoto(itemId: string, photo: Omit<ItemPhoto, 'id' | 'order'>) {
const item = items.find((i) => i.id === itemId);
const newPhoto: ItemPhoto = { ...photo, id: generateId(), order: item?.photos.length || 0 };
items = items.map((i) =>
i.id === itemId
? { ...i, photos: [...i.photos, newPhoto], updatedAt: new Date().toISOString() }
: i
);
saveToStorage(items);
async addPhoto(itemId: string, photo: Omit<ItemPhoto, 'id' | 'order'>) {
const item = await itemCollection.get(itemId);
if (!item) return;
const newPhoto: ItemPhoto = { ...photo, id: crypto.randomUUID(), order: item.photos.length };
await itemCollection.update(itemId, {
photos: [...item.photos, newPhoto],
});
},
deletePhoto(itemId: string, photoId: string) {
items = items.map((i) =>
i.id === itemId
? {
...i,
photos: i.photos.filter((p) => p.id !== photoId),
updatedAt: new Date().toISOString(),
}
: i
);
saveToStorage(items);
},
getCountByCollection(collectionId: string): number {
return items.filter((i) => i.collectionId === collectionId).length;
},
getTotalCount(): number {
return items.length;
},
getCountByStatus(status: ItemStatus): number {
return items.filter((i) => i.status === status).length;
async deletePhoto(itemId: string, photoId: string) {
const item = await itemCollection.get(itemId);
if (!item) return;
await itemCollection.update(itemId, {
photos: item.photos.filter((p) => p.id !== photoId),
});
},
};

View file

@ -1,124 +1,61 @@
import { browser } from '$app/environment';
import type { Location } from '@inventar/shared';
/**
* Locations Store Mutations Only
*
* Reads come from useLiveQuery (see $lib/data/queries.ts).
* This store only handles writes to IndexedDB via local-store.
*/
const STORAGE_KEY = 'inventar_locations';
import { locationCollection, type LocalLocation } from '$lib/data/local-store';
import { toLocation } from '$lib/data/queries';
function loadFromStorage(): Location[] {
if (!browser) return [];
try {
const data = localStorage.getItem(STORAGE_KEY);
return data ? JSON.parse(data) : [];
} catch {
return [];
}
}
function saveToStorage(locations: Location[]) {
if (!browser) return;
localStorage.setItem(STORAGE_KEY, JSON.stringify(locations));
}
function generateId(): string {
return crypto.randomUUID();
}
function buildPath(locations: Location[], parentId?: string): string {
function buildPath(locations: LocalLocation[], parentId?: string): string {
if (!parentId) return '';
const parent = locations.find((l) => l.id === parentId);
if (!parent) return '';
return parent.path ? `${parent.path}/${parent.name}` : parent.name;
}
function getDepth(locations: Location[], parentId?: string): number {
function getDepth(locations: LocalLocation[], parentId?: string): number {
if (!parentId) return 0;
const parent = locations.find((l) => l.id === parentId);
return parent ? parent.depth + 1 : 0;
}
let locations = $state<Location[]>([]);
let initialized = $state(false);
export const locationsStore = {
get locations() {
return locations;
},
get initialized() {
return initialized;
},
async create(data: { name: string; description?: string; icon?: string; parentId?: string }) {
const all = await locationCollection.getAll();
const path = buildPath(all, data.parentId);
const depth = getDepth(all, data.parentId);
const siblings = all.filter((l) => l.parentId === data.parentId);
initialize() {
if (initialized) return;
locations = loadFromStorage();
initialized = true;
},
getById(id: string): Location | undefined {
return locations.find((l) => l.id === id);
},
getRootLocations(): Location[] {
return locations.filter((l) => !l.parentId).sort((a, b) => a.order - b.order);
},
getChildren(parentId: string): Location[] {
return locations.filter((l) => l.parentId === parentId).sort((a, b) => a.order - b.order);
},
getTree(): Location[] {
const buildTree = (parentId?: string): Location[] => {
return locations
.filter((l) => l.parentId === parentId)
.sort((a, b) => a.order - b.order)
.map((l) => ({ ...l, children: buildTree(l.id) }));
};
return buildTree(undefined);
},
getFullPath(id: string): string {
const location = locations.find((l) => l.id === id);
if (!location) return '';
return location.path ? `${location.path}/${location.name}` : location.name;
},
create(data: { name: string; description?: string; icon?: string; parentId?: string }): Location {
const now = new Date().toISOString();
const path = buildPath(locations, data.parentId);
const depth = getDepth(locations, data.parentId);
const siblings = locations.filter((l) => l.parentId === data.parentId);
const location: Location = {
id: generateId(),
parentId: data.parentId,
const newLocal: LocalLocation = {
id: crypto.randomUUID(),
parentId: data.parentId ?? null,
name: data.name,
description: data.description,
icon: data.icon,
description: data.description ?? null,
icon: data.icon ?? null,
path,
depth,
order: siblings.length,
createdAt: now,
updatedAt: now,
};
locations = [...locations, location];
saveToStorage(locations);
return location;
const inserted = await locationCollection.insert(newLocal);
return toLocation(inserted);
},
update(id: string, data: Partial<Pick<Location, 'name' | 'description' | 'icon'>>) {
locations = locations.map((l) =>
l.id === id ? { ...l, ...data, updatedAt: new Date().toISOString() } : l
);
saveToStorage(locations);
async update(id: string, data: Partial<Pick<LocalLocation, 'name' | 'description' | 'icon'>>) {
await locationCollection.update(id, data);
},
delete(id: string) {
// Delete location and all children
async delete(id: string) {
const all = await locationCollection.getAll();
const idsToDelete = new Set<string>();
const collectIds = (parentId: string) => {
idsToDelete.add(parentId);
locations.filter((l) => l.parentId === parentId).forEach((l) => collectIds(l.id));
all.filter((l) => l.parentId === parentId).forEach((l) => collectIds(l.id));
};
collectIds(id);
locations = locations.filter((l) => !idsToDelete.has(l.id));
saveToStorage(locations);
for (const deleteId of idsToDelete) {
await locationCollection.delete(deleteId);
}
},
};

View file

@ -2,11 +2,8 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { _ } from 'svelte-i18n';
import { setContext } from 'svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { collectionsStore } from '$lib/stores/collections.svelte';
import { itemsStore } from '$lib/stores/items.svelte';
import { locationsStore } from '$lib/stores/locations.svelte';
import { categoriesStore } from '$lib/stores/categories.svelte';
import { viewStore } from '$lib/stores/view.svelte';
import { theme } from '$lib/stores/theme';
import { setLocale, supportedLocales } from '$lib/i18n';
@ -15,6 +12,12 @@
import { AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
import { inventarStore } from '$lib/data/local-store';
import {
useAllCollections,
useAllItems,
useAllLocations,
useAllCategories,
} from '$lib/data/queries';
let { children } = $props();
@ -22,6 +25,18 @@
let initialized = $state(false);
let showGuestWelcome = $state(false);
// Live queries — auto-update when IndexedDB changes (local writes, sync, other tabs)
const allCollections = useAllCollections();
const allItems = useAllItems();
const allLocations = useAllLocations();
const allCategories = useAllCategories();
// Provide data to child components via Svelte context
setContext('collections', allCollections);
setContext('items', allItems);
setContext('locations', allLocations);
setContext('categories', allCategories);
async function handleAuthReady() {
// Initialize local-first database
await inventarStore.initialize();
@ -31,11 +46,7 @@
inventarStore.startSync(() => authStore.getValidToken());
}
// Initialize legacy localStorage stores (will be migrated to IndexedDB later)
collectionsStore.initialize();
itemsStore.initialize();
locationsStore.initialize();
categoriesStore.initialize();
// Initialize view preferences (still localStorage-based, not data)
viewStore.initialize();
initialized = true;

View file

@ -1,12 +1,21 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { getContext } from 'svelte';
import { collectionsStore } from '$lib/stores/collections.svelte';
import { itemsStore } from '$lib/stores/items.svelte';
import type { Collection } from '@inventar/shared';
import {
getSortedCollections,
getItemCountByCollection,
getTotalItemCount,
} from '$lib/data/queries';
import type { Collection, Item } from '@inventar/shared';
const collectionsCtx: { readonly value: Collection[] } = getContext('collections');
const itemsCtx: { readonly value: Item[] } = getContext('items');
function getItemCount(collectionId: string): number {
return itemsStore.getCountByCollection(collectionId);
return getItemCountByCollection(itemsCtx.value, collectionId);
}
function handleCollectionClick(collection: Collection) {
@ -22,8 +31,9 @@
}
// Stats
let totalItems = $derived(itemsStore.getTotalCount());
let totalCollections = $derived(collectionsStore.collections.length);
let totalItems = $derived(getTotalItemCount(itemsCtx.value));
let totalCollections = $derived(collectionsCtx.value.length);
let sortedCollections = $derived(getSortedCollections(collectionsCtx.value));
</script>
<svelte:head>
@ -51,7 +61,7 @@
</div>
<!-- Collections grid -->
{#if collectionsStore.collections.length === 0}
{#if sortedCollections.length === 0}
<div
class="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-[hsl(var(--border))] py-16"
>
@ -71,7 +81,7 @@
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each collectionsStore.collections.sort((a, b) => a.order - b.order) as collection (collection.id)}
{#each sortedCollections as collection (collection.id)}
<div
role="button"
tabindex="0"

View file

@ -1,8 +1,11 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { getContext } from 'svelte';
import { categoriesStore } from '$lib/stores/categories.svelte';
import type { Category } from '@inventar/shared';
const categoriesCtx: { readonly value: Category[] } = getContext('categories');
let showForm = $state(false);
let editingId = $state<string | null>(null);
let name = $state('');
@ -25,16 +28,16 @@
showForm = true;
}
function save() {
async function save() {
if (!name.trim()) return;
if (editingId) {
categoriesStore.update(editingId, {
await categoriesStore.update(editingId, {
name: name.trim(),
icon: icon || undefined,
color: color || undefined,
});
} else {
categoriesStore.create({
await categoriesStore.create({
name: name.trim(),
icon: icon || undefined,
color: color || undefined,
@ -43,12 +46,14 @@
showForm = false;
}
function deleteCategory(id: string) {
async function deleteCategory(id: string) {
if (confirm('Kategorie löschen?')) {
categoriesStore.delete(id);
await categoriesStore.delete(id);
}
}
let sortedCategories = $derived([...categoriesCtx.value].sort((a, b) => a.order - b.order));
const inputClass =
'rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]';
</script>
@ -108,7 +113,7 @@
</div>
{/if}
{#if categoriesStore.categories.length === 0}
{#if sortedCategories.length === 0}
<div
class="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-[hsl(var(--border))] py-16"
>
@ -117,7 +122,7 @@
</div>
{:else}
<div class="grid gap-3 sm:grid-cols-2">
{#each categoriesStore.categories.sort((a, b) => a.order - b.order) as category (category.id)}
{#each sortedCategories as category (category.id)}
<div
class="group flex items-center gap-3 rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] px-4 py-3"
>

View file

@ -2,21 +2,23 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { collectionsStore } from '$lib/stores/collections.svelte';
import { getContext } from 'svelte';
import { itemsStore } from '$lib/stores/items.svelte';
import { viewStore } from '$lib/stores/view.svelte';
import { locationsStore } from '$lib/stores/locations.svelte';
import { categoriesStore } from '$lib/stores/categories.svelte';
import type { Item, ItemStatus } from '@inventar/shared';
import { getCollectionById, getItemsByCollection, getSortedItems } from '$lib/data/queries';
import type { Collection, Item, ItemStatus } from '@inventar/shared';
import FieldRenderer from '$lib/components/fields/FieldRenderer.svelte';
import FieldEditor from '$lib/components/fields/FieldEditor.svelte';
import StatusBadge from '$lib/components/StatusBadge.svelte';
import ViewModeToggle from '$lib/components/ViewModeToggle.svelte';
const collectionsCtx: { readonly value: Collection[] } = getContext('collections');
const itemsCtx: { readonly value: Item[] } = getContext('items');
let collectionId = $derived($page.params.id);
let collection = $derived(collectionsStore.getById(collectionId));
let items = $derived(itemsStore.getByCollection(collectionId));
let sortedItems = $derived(itemsStore.getSorted(items, viewStore.sort));
let collection = $derived(getCollectionById(collectionsCtx.value, collectionId));
let items = $derived(getItemsByCollection(itemsCtx.value, collectionId));
let sortedItems = $derived(getSortedItems(items, viewStore.sort));
// Item creation
let showNewItem = $state(false);
@ -24,9 +26,9 @@
let newItemFields = $state<Record<string, unknown>>({});
let newItemStatus = $state<ItemStatus>('owned');
function createItem() {
async function createItem() {
if (!newItemName.trim() || !collection) return;
itemsStore.create({
await itemsStore.create({
collectionId: collection.id,
name: newItemName.trim(),
status: newItemStatus,

View file

@ -2,12 +2,16 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { getContext } from 'svelte';
import { collectionsStore } from '$lib/stores/collections.svelte';
import type { CollectionSchema } from '@inventar/shared';
import { getCollectionById } from '$lib/data/queries';
import type { Collection, CollectionSchema } from '@inventar/shared';
import SchemaEditor from '$lib/components/fields/SchemaEditor.svelte';
const collectionsCtx: { readonly value: Collection[] } = getContext('collections');
let collectionId = $derived($page.params.id);
let collection = $derived(collectionsStore.getById(collectionId));
let collection = $derived(getCollectionById(collectionsCtx.value, collectionId));
let name = $state('');
let description = $state('');
@ -25,9 +29,9 @@
}
});
function handleSave() {
async function handleSave() {
if (!collection || !name.trim()) return;
collectionsStore.update(collection.id, {
await collectionsStore.update(collection.id, {
name: name.trim(),
description: description.trim() || undefined,
icon: icon || undefined,

View file

@ -23,9 +23,9 @@
step = 'details';
}
function handleCreate() {
async function handleCreate() {
if (!name.trim()) return;
collectionsStore.create({
await collectionsStore.create({
name: name.trim(),
description: description.trim() || undefined,
icon: icon || undefined,

View file

@ -1,22 +1,25 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { itemsStore } from '$lib/stores/items.svelte';
import { collectionsStore } from '$lib/stores/collections.svelte';
import { getContext } from 'svelte';
import { viewStore } from '$lib/stores/view.svelte';
import { getFilteredItems, getSortedItems, getCollectionById } from '$lib/data/queries';
import type { Collection, Item, ItemStatus } from '@inventar/shared';
import StatusBadge from '$lib/components/StatusBadge.svelte';
import ViewModeToggle from '$lib/components/ViewModeToggle.svelte';
import type { ItemStatus } from '@inventar/shared';
const collectionsCtx: { readonly value: Collection[] } = getContext('collections');
const itemsCtx: { readonly value: Item[] } = getContext('items');
let searchQuery = $state('');
let filteredItems = $derived(
itemsStore.getFiltered({
getFilteredItems(itemsCtx.value, {
search: searchQuery || undefined,
...viewStore.activeFilters,
})
);
let sortedItems = $derived(itemsStore.getSorted(filteredItems, viewStore.sort));
let sortedItems = $derived(getSortedItems(filteredItems, viewStore.sort));
const statuses: ItemStatus[] = ['owned', 'lent', 'stored', 'for_sale', 'disposed'];
@ -33,7 +36,7 @@
}
function getCollectionName(collectionId: string): string {
return collectionsStore.getById(collectionId)?.name || '';
return getCollectionById(collectionsCtx.value, collectionId)?.name || '';
}
</script>

View file

@ -2,18 +2,30 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { getContext } from 'svelte';
import { itemsStore } from '$lib/stores/items.svelte';
import { collectionsStore } from '$lib/stores/collections.svelte';
import { locationsStore } from '$lib/stores/locations.svelte';
import { categoriesStore } from '$lib/stores/categories.svelte';
import type { ItemStatus } from '@inventar/shared';
import {
getItemById,
getCollectionById,
getLocationById,
getLocationFullPath,
getCategoryById,
} from '$lib/data/queries';
import type { Collection, Item, Location, Category, ItemStatus } from '@inventar/shared';
import FieldRenderer from '$lib/components/fields/FieldRenderer.svelte';
import FieldEditor from '$lib/components/fields/FieldEditor.svelte';
import StatusBadge from '$lib/components/StatusBadge.svelte';
const collectionsCtx: { readonly value: Collection[] } = getContext('collections');
const itemsCtx: { readonly value: Item[] } = getContext('items');
const locationsCtx: { readonly value: Location[] } = getContext('locations');
const categoriesCtx: { readonly value: Category[] } = getContext('categories');
let itemId = $derived($page.params.id);
let item = $derived(itemsStore.getById(itemId));
let collection = $derived(item ? collectionsStore.getById(item.collectionId) : undefined);
let item = $derived(getItemById(itemsCtx.value, itemId));
let collection = $derived(
item ? getCollectionById(collectionsCtx.value, item.collectionId) : undefined
);
let editing = $state(false);
let editName = $state('');
@ -41,9 +53,9 @@
editing = true;
}
function saveEdit() {
async function saveEdit() {
if (!item || !editName.trim()) return;
itemsStore.update(item.id, {
await itemsStore.update(item.id, {
name: editName.trim(),
description: editDescription.trim() || undefined,
status: editStatus,
@ -55,15 +67,15 @@
editing = false;
}
function addNote() {
async function addNote() {
if (!item || !newNote.trim()) return;
itemsStore.addNote(item.id, newNote.trim());
await itemsStore.addNote(item.id, newNote.trim());
newNote = '';
}
function deleteItem() {
async function deleteItem() {
if (!item || !confirm('Item endgültig löschen?')) return;
itemsStore.delete(item.id);
await itemsStore.delete(item.id);
goto(collection ? `/collections/${collection.id}` : '/items');
}
@ -165,27 +177,27 @@
>
<input type="number" bind:value={editQuantity} min="1" class={inputClass} />
</div>
{#if locationsStore.locations.length > 0}
{#if locationsCtx.value.length > 0}
<div>
<label class="mb-1 block text-xs font-medium text-[hsl(var(--muted-foreground))]"
>{$_('item.location')}</label
>
<select bind:value={editLocationId} class={inputClass}>
<option value={undefined}> Kein Standort —</option>
{#each locationsStore.locations as loc}
{#each locationsCtx.value as loc}
<option value={loc.id}>{loc.path ? `${loc.path}/` : ''}{loc.name}</option>
{/each}
</select>
</div>
{/if}
{#if categoriesStore.categories.length > 0}
{#if categoriesCtx.value.length > 0}
<div>
<label class="mb-1 block text-xs font-medium text-[hsl(var(--muted-foreground))]"
>{$_('item.category')}</label
>
<select bind:value={editCategoryId} class={inputClass}>
<option value={undefined}> Keine Kategorie —</option>
{#each categoriesStore.categories as cat}
{#each categoriesCtx.value as cat}
<option value={cat.id}>{cat.name}</option>
{/each}
</select>
@ -227,15 +239,15 @@
>
{/if}
{#if item.locationId}
{@const loc = locationsStore.getById(item.locationId)}
{@const loc = getLocationById(locationsCtx.value, item.locationId)}
{#if loc}
<span class="flex items-center gap-1 text-sm text-[hsl(var(--muted-foreground))]">
📍 {locationsStore.getFullPath(loc.id)}
📍 {getLocationFullPath(locationsCtx.value, loc.id)}
</span>
{/if}
{/if}
{#if item.categoryId}
{@const cat = categoriesStore.getById(item.categoryId)}
{@const cat = getCategoryById(categoriesCtx.value, item.categoryId)}
{#if cat}
<span class="rounded-full bg-[hsl(var(--muted))] px-2 py-0.5 text-xs"
>{cat.icon || '🏷️'} {cat.name}</span

View file

@ -1,8 +1,12 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { getContext } from 'svelte';
import { locationsStore } from '$lib/stores/locations.svelte';
import { getLocationTree } from '$lib/data/queries';
import type { Location } from '@inventar/shared';
const locationsCtx: { readonly value: Location[] } = getContext('locations');
let showForm = $state(false);
let editingId = $state<string | null>(null);
let parentId = $state<string | undefined>();
@ -25,12 +29,12 @@
showForm = true;
}
function save() {
async function save() {
if (!name.trim()) return;
if (editingId) {
locationsStore.update(editingId, { name: name.trim(), icon: icon || undefined });
await locationsStore.update(editingId, { name: name.trim(), icon: icon || undefined });
} else {
locationsStore.create({ name: name.trim(), icon: icon || undefined, parentId });
await locationsStore.create({ name: name.trim(), icon: icon || undefined, parentId });
}
showForm = false;
name = '';
@ -38,13 +42,13 @@
editingId = null;
}
function deleteLocation(id: string) {
async function deleteLocation(id: string) {
if (confirm('Standort und alle Unterstandorte löschen?')) {
locationsStore.delete(id);
await locationsStore.delete(id);
}
}
let tree = $derived(locationsStore.getTree());
let tree = $derived(getLocationTree(locationsCtx.value));
const inputClass =
'rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]';

View file

@ -1,12 +1,18 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { itemsStore } from '$lib/stores/items.svelte';
import { collectionsStore } from '$lib/stores/collections.svelte';
import { getContext } from 'svelte';
import { getFilteredItems, getCollectionById } from '$lib/data/queries';
import type { Collection, Item } from '@inventar/shared';
import StatusBadge from '$lib/components/StatusBadge.svelte';
const collectionsCtx: { readonly value: Collection[] } = getContext('collections');
const itemsCtx: { readonly value: Item[] } = getContext('items');
let query = $state('');
let results = $derived(query.length >= 2 ? itemsStore.getFiltered({ search: query }) : []);
let results = $derived(
query.length >= 2 ? getFilteredItems(itemsCtx.value, { search: query }) : []
);
</script>
<svelte:head>
@ -53,7 +59,7 @@
<StatusBadge status={item.status} />
</div>
<p class="text-xs text-[hsl(var(--muted-foreground))]">
{collectionsStore.getById(item.collectionId)?.name || ''}
{getCollectionById(collectionsCtx.value, item.collectionId)?.name || ''}
</p>
</div>
</button>

View file

@ -1,20 +1,15 @@
<script lang="ts">
import { mealsStore } from '$lib/stores/meals.svelte';
import { onMount } from 'svelte';
import { useAllMeals, useAllGoals, getDailySummary } from '$lib/data/queries';
import ProgressRing from './ProgressRing.svelte';
import { WarningCircle, ArrowsClockwise } from '@manacore/shared-icons';
onMount(() => {
mealsStore.fetchDailySummary();
});
// Reactive live queries — auto-update when IndexedDB changes
const allMeals = useAllMeals();
const allGoals = useAllGoals();
const currentGoal = $derived(allGoals.value[0] ?? null);
const summary = $derived(getDailySummary(allMeals.value, undefined, currentGoal));
let progress = $derived(mealsStore.dailySummary?.progress);
let progress = $derived(summary?.progress);
let caloriePercent = $derived(progress?.calories?.percentage ?? 0);
function retry() {
mealsStore.clearErrors();
mealsStore.fetchDailySummary();
}
</script>
<div class="bg-[var(--color-background-card)] rounded-2xl p-4 border border-[var(--color-border)]">
@ -26,88 +21,64 @@
</span>
</div>
{#if mealsStore.summaryError}
<div
class="bg-red-500/10 border border-red-500/20 rounded-xl p-3 flex items-center gap-2 text-red-400 text-sm"
<!-- Calories Ring -->
<div class="flex items-center gap-6">
<ProgressRing
percentage={caloriePercent}
size={100}
strokeWidth={8}
color="var(--color-calories)"
>
<WarningCircle class="w-4 h-4 flex-shrink-0" />
<span class="flex-1">{mealsStore.summaryError}</span>
<button onclick={retry} class="p-1 hover:bg-red-500/20 rounded transition-colors">
<ArrowsClockwise class="w-4 h-4" />
</button>
</div>
{:else if mealsStore.summaryLoading}
<div class="flex items-center gap-6 animate-pulse">
<div class="w-[100px] h-[100px] rounded-full bg-[var(--color-background-elevated)]"></div>
<div class="flex-1 grid grid-cols-3 gap-2">
{#each [1, 2, 3] as _}
<div class="text-center">
<div class="h-5 bg-[var(--color-background-elevated)] rounded mb-1"></div>
<div class="h-3 bg-[var(--color-background-elevated)] rounded w-12 mx-auto"></div>
</div>
{/each}
<div class="text-center">
<div class="text-2xl font-bold text-[var(--color-text-primary)]">
{progress?.calories?.current ?? 0}
</div>
<div class="text-xs text-[var(--color-text-secondary)]">
/ {progress?.calories?.target ?? 2000}
</div>
</div>
</div>
{:else}
<!-- Calories Ring -->
<div class="flex items-center gap-6">
<ProgressRing
percentage={caloriePercent}
size={100}
strokeWidth={8}
color="var(--color-calories)"
>
<div class="text-center">
<div class="text-2xl font-bold text-[var(--color-text-primary)]">
{progress?.calories?.current ?? 0}
</div>
<div class="text-xs text-[var(--color-text-secondary)]">
/ {progress?.calories?.target ?? 2000}
</div>
</div>
</ProgressRing>
</ProgressRing>
<!-- Macros -->
<div class="flex-1 grid grid-cols-3 gap-2">
<div class="text-center">
<div class="text-sm font-medium text-[var(--color-protein)]">
{progress?.protein?.current ?? 0}g
</div>
<div class="text-xs text-[var(--color-text-muted)]">Protein</div>
<div class="mt-1 h-1 bg-[var(--color-background-elevated)] rounded-full overflow-hidden">
<div
class="h-full bg-[var(--color-protein)] transition-all"
style="width: {progress?.protein?.percentage ?? 0}%"
></div>
</div>
<!-- Macros -->
<div class="flex-1 grid grid-cols-3 gap-2">
<div class="text-center">
<div class="text-sm font-medium text-[var(--color-protein)]">
{progress?.protein?.current ?? 0}g
</div>
<div class="text-center">
<div class="text-sm font-medium text-[var(--color-carbs)]">
{progress?.carbs?.current ?? 0}g
</div>
<div class="text-xs text-[var(--color-text-muted)]">Carbs</div>
<div class="mt-1 h-1 bg-[var(--color-background-elevated)] rounded-full overflow-hidden">
<div
class="h-full bg-[var(--color-carbs)] transition-all"
style="width: {progress?.carbs?.percentage ?? 0}%"
></div>
</div>
<div class="text-xs text-[var(--color-text-muted)]">Protein</div>
<div class="mt-1 h-1 bg-[var(--color-background-elevated)] rounded-full overflow-hidden">
<div
class="h-full bg-[var(--color-protein)] transition-all"
style="width: {progress?.protein?.percentage ?? 0}%"
></div>
</div>
</div>
<div class="text-center">
<div class="text-sm font-medium text-[var(--color-fat)]">
{progress?.fat?.current ?? 0}g
</div>
<div class="text-xs text-[var(--color-text-muted)]">Fett</div>
<div class="mt-1 h-1 bg-[var(--color-background-elevated)] rounded-full overflow-hidden">
<div
class="h-full bg-[var(--color-fat)] transition-all"
style="width: {progress?.fat?.percentage ?? 0}%"
></div>
</div>
<div class="text-center">
<div class="text-sm font-medium text-[var(--color-carbs)]">
{progress?.carbs?.current ?? 0}g
</div>
<div class="text-xs text-[var(--color-text-muted)]">Carbs</div>
<div class="mt-1 h-1 bg-[var(--color-background-elevated)] rounded-full overflow-hidden">
<div
class="h-full bg-[var(--color-carbs)] transition-all"
style="width: {progress?.carbs?.percentage ?? 0}%"
></div>
</div>
</div>
<div class="text-center">
<div class="text-sm font-medium text-[var(--color-fat)]">
{progress?.fat?.current ?? 0}g
</div>
<div class="text-xs text-[var(--color-text-muted)]">Fett</div>
<div class="mt-1 h-1 bg-[var(--color-background-elevated)] rounded-full overflow-hidden">
<div
class="h-full bg-[var(--color-fat)] transition-all"
style="width: {progress?.fat?.percentage ?? 0}%"
></div>
</div>
</div>
</div>
{/if}
</div>
</div>

View file

@ -1,74 +1,49 @@
<script lang="ts">
import { mealsStore } from '$lib/stores/meals.svelte';
import { onMount } from 'svelte';
import { useAllMeals, getTodaysMeals } from '$lib/data/queries';
import { MEAL_TYPE_LABELS } from '@nutriphi/shared';
import {
Trash,
Camera,
PencilLine,
WarningCircle,
ArrowsClockwise,
CircleNotch,
} from '@manacore/shared-icons';
import { Trash, Camera, PencilLine, CircleNotch } from '@manacore/shared-icons';
// Reactive live query — auto-updates when IndexedDB changes
const allMeals = useAllMeals();
const todaysMeals = $derived(getTodaysMeals(allMeals.value));
let deleting = $state<string | null>(null);
onMount(() => {
mealsStore.fetchTodaysMeals();
});
let deleteError = $state<string | null>(null);
async function deleteMeal(id: string) {
if (confirm('Mahlzeit wirklich löschen?')) {
if (confirm('Mahlzeit wirklich loschen?')) {
deleting = id;
deleteError = null;
try {
await mealsStore.deleteMeal(id);
} catch {
// Error is handled in store
} catch (err) {
deleteError = err instanceof Error ? err.message : 'Mahlzeit konnte nicht geloscht werden';
} finally {
deleting = null;
}
}
}
function retry() {
mealsStore.clearErrors();
mealsStore.fetchTodaysMeals();
}
</script>
<div class="space-y-3">
{#if mealsStore.error}
<div
class="bg-red-500/10 border border-red-500/20 rounded-xl p-4 flex items-center gap-3 text-red-400"
>
<WarningCircle class="w-5 h-5 flex-shrink-0" />
<span class="flex-1 text-sm">{mealsStore.error}</span>
<button onclick={retry} class="p-2 hover:bg-red-500/20 rounded-lg transition-colors">
<ArrowsClockwise class="w-4 h-4" />
</button>
</div>
{/if}
{#if mealsStore.deleteError}
{#if deleteError}
<div
class="bg-red-500/10 border border-red-500/20 rounded-xl p-3 flex items-center gap-2 text-red-400 text-sm"
>
<WarningCircle class="w-4 h-4 flex-shrink-0" />
<span>{mealsStore.deleteError}</span>
<span>{deleteError}</span>
</div>
{/if}
{#if mealsStore.loading}
<div class="text-center py-8 text-[var(--color-text-secondary)]">Laden...</div>
{:else if !mealsStore.error && mealsStore.meals.length === 0}
{#if todaysMeals.length === 0}
<div class="text-center py-8">
<p class="text-[var(--color-text-secondary)] mb-2">Noch keine Mahlzeiten heute</p>
<p class="text-sm text-[var(--color-text-muted)]">
Tippe auf + um deine erste Mahlzeit hinzuzufügen
Tippe auf + um deine erste Mahlzeit hinzuzufugen
</p>
</div>
{:else}
{#each mealsStore.meals as meal (meal.id)}
{#each todaysMeals as meal (meal.id)}
<div
class="bg-[var(--color-background-card)] rounded-xl p-4 border border-[var(--color-border)]"
>

View file

@ -0,0 +1,174 @@
/**
* Reactive Queries & Pure Helpers for NutriPhi
*
* Uses Dexie liveQuery to automatically re-render when IndexedDB changes
* (local writes, sync updates, other tabs). Components call these hooks
* at init time; no manual fetch/refresh needed.
*/
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import {
mealCollection,
goalCollection,
favoriteCollection,
type LocalMeal,
type LocalGoal,
type LocalFavorite,
} from './local-store';
import type { Meal, MealNutrition, DailySummary, NutritionProgress } from '@nutriphi/shared';
// ─── Extended Types ────────────────────────────────────────
export interface MealWithNutrition extends Meal {
nutrition: MealNutrition | null;
}
// ─── Type Converters ───────────────────────────────────────
export function toMealWithNutrition(local: LocalMeal): MealWithNutrition {
return {
id: local.id,
userId: 'local',
date: new Date(local.date),
mealType: local.mealType as any,
inputType: local.inputType as any,
description: local.description,
portionSize: local.portionSize ?? undefined,
confidence: local.confidence,
createdAt: new Date(local.createdAt ?? Date.now()),
nutrition: local.nutrition
? {
id: local.id,
mealId: local.id,
calories: local.nutrition.calories,
protein: local.nutrition.protein,
carbohydrates: local.nutrition.carbohydrates,
fat: local.nutrition.fat,
fiber: local.nutrition.fiber,
sugar: local.nutrition.sugar,
}
: null,
} as MealWithNutrition;
}
// ─── Live Query Hooks (call during component init) ─────────
/** All meals, auto-updates on any change. */
export function useAllMeals() {
return useLiveQueryWithDefault(async () => {
const locals = await mealCollection.getAll();
return locals.map(toMealWithNutrition);
}, [] as MealWithNutrition[]);
}
/** All goals, auto-updates on any change. */
export function useAllGoals() {
return useLiveQueryWithDefault(async () => {
return await goalCollection.getAll();
}, [] as LocalGoal[]);
}
/** All favorites, auto-updates on any change. */
export function useAllFavorites() {
return useLiveQueryWithDefault(async () => {
return await favoriteCollection.getAll();
}, [] as LocalFavorite[]);
}
// ─── Pure Filter/Helper Functions (for $derived) ──────────
/** Filter meals for a specific date string (YYYY-MM-DD). */
export function filterByDate(meals: MealWithNutrition[], dateStr: string): MealWithNutrition[] {
return meals.filter((m) => {
const mealDate =
m.date instanceof Date ? m.date.toISOString().split('T')[0] : String(m.date).split('T')[0];
return mealDate === dateStr;
});
}
/** Get today's date as YYYY-MM-DD string. */
export function getTodayStr(): string {
return new Date().toISOString().split('T')[0];
}
/** Filter meals for today, sorted by creation time. */
export function getTodaysMeals(meals: MealWithNutrition[]): MealWithNutrition[] {
const today = getTodayStr();
return filterByDate(meals, today).sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
);
}
/** Sum nutrition values across a set of meals. */
export function sumNutrition(meals: MealWithNutrition[]): {
calories: number;
protein: number;
carbohydrates: number;
fat: number;
fiber: number;
sugar: number;
} {
return meals.reduce(
(acc, m) => ({
calories: acc.calories + (m.nutrition?.calories || 0),
protein: acc.protein + (m.nutrition?.protein || 0),
carbohydrates: acc.carbohydrates + (m.nutrition?.carbohydrates || 0),
fat: acc.fat + (m.nutrition?.fat || 0),
fiber: acc.fiber + (m.nutrition?.fiber || 0),
sugar: acc.sugar + (m.nutrition?.sugar || 0),
}),
{ calories: 0, protein: 0, carbohydrates: 0, fat: 0, fiber: 0, sugar: 0 }
);
}
/** Build a DailySummary from meals for a given date. */
export function getDailySummary(
meals: MealWithNutrition[],
date?: Date,
goals?: LocalGoal | null
): DailySummary {
const dateStr = (date || new Date()).toISOString().split('T')[0];
const dayMeals = filterByDate(meals, dateStr);
const totalNutrition = sumNutrition(dayMeals);
const calorieTarget = goals?.dailyCalories ?? 2000;
const proteinTarget = goals?.dailyProtein ?? 50;
const carbsTarget = goals?.dailyCarbs ?? 250;
const fatTarget = goals?.dailyFat ?? 65;
const progress: NutritionProgress = {
calories: {
current: Math.round(totalNutrition.calories),
target: calorieTarget,
percentage: Math.min(Math.round((totalNutrition.calories / calorieTarget) * 100), 100),
},
protein: {
current: Math.round(totalNutrition.protein),
target: proteinTarget,
percentage: Math.min(Math.round((totalNutrition.protein / proteinTarget) * 100), 100),
},
carbs: {
current: Math.round(totalNutrition.carbohydrates),
target: carbsTarget,
percentage: Math.min(Math.round((totalNutrition.carbohydrates / carbsTarget) * 100), 100),
},
fat: {
current: Math.round(totalNutrition.fat),
target: fatTarget,
percentage: Math.min(Math.round((totalNutrition.fat / fatTarget) * 100), 100),
},
};
return {
date: new Date(dateStr),
meals: dayMeals,
totalNutrition,
progress,
} as DailySummary;
}
/** Search meals by description. */
export function searchMeals(meals: MealWithNutrition[], query: string): MealWithNutrition[] {
const q = query.toLowerCase();
return meals.filter((m) => m.description?.toLowerCase().includes(q));
}

View file

@ -1,168 +1,56 @@
/**
* Meals Store Local-First with @manacore/local-store
* Meals Store Write Actions Only
*
* All reads and writes go to IndexedDB first.
* When authenticated, changes sync to the server in the background.
* Reads are handled by useLiveQuery hooks in queries.ts.
* This store only exposes mutation actions that write to IndexedDB.
*/
import { mealCollection, type LocalMeal } from '$lib/data/local-store';
import type { Meal, MealNutrition, DailySummary } from '@nutriphi/shared';
import { NutriPhiEvents } from '@manacore/shared-utils/analytics';
interface MealWithNutrition extends Meal {
nutrition: MealNutrition | null;
// ─── Actions ─────────────────────────────────────────────────
async function addMeal(mealData: {
date: string;
mealType: string;
inputType: string;
description: string;
confidence: number;
calories: number;
protein: number;
carbohydrates: number;
fat: number;
fiber?: number;
sugar?: number;
}) {
const newMeal: LocalMeal = {
id: crypto.randomUUID(),
date: mealData.date,
mealType: mealData.mealType as any,
inputType: mealData.inputType as any,
description: mealData.description,
confidence: mealData.confidence,
nutrition: {
calories: mealData.calories,
protein: mealData.protein,
carbohydrates: mealData.carbohydrates,
fat: mealData.fat,
fiber: mealData.fiber || 0,
sugar: mealData.sugar || 0,
},
};
const inserted = await mealCollection.insert(newMeal);
NutriPhiEvents.mealAdded(mealData.mealType, mealData.inputType);
return inserted;
}
function toMealWithNutrition(local: LocalMeal): MealWithNutrition {
return {
id: local.id,
userId: 'local',
date: new Date(local.date),
mealType: local.mealType as any,
inputType: local.inputType as any,
description: local.description,
portionSize: local.portionSize ?? undefined,
confidence: local.confidence,
createdAt: new Date(local.createdAt ?? Date.now()),
nutrition: local.nutrition
? {
id: local.id,
mealId: local.id,
calories: local.nutrition.calories,
protein: local.nutrition.protein,
carbohydrates: local.nutrition.carbohydrates,
fat: local.nutrition.fat,
fiber: local.nutrition.fiber,
sugar: local.nutrition.sugar,
}
: null,
} as MealWithNutrition;
async function deleteMeal(mealId: string) {
await mealCollection.delete(mealId);
NutriPhiEvents.mealDeleted();
}
class MealsStore {
meals = $state<MealWithNutrition[]>([]);
loading = $state(false);
error = $state<string | null>(null);
dailySummary = $state<DailySummary | null>(null);
summaryLoading = $state(false);
summaryError = $state<string | null>(null);
deleteError = $state<string | null>(null);
async fetchTodaysMeals() {
this.loading = true;
this.error = null;
try {
const today = new Date().toISOString().split('T')[0];
const allMeals = await mealCollection.getAll();
this.meals = allMeals
.filter((m) => m.date === today)
.map(toMealWithNutrition)
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
} catch (err) {
this.error = err instanceof Error ? err.message : 'Mahlzeiten konnten nicht geladen werden';
} finally {
this.loading = false;
}
}
async fetchDailySummary(date?: Date) {
this.summaryLoading = true;
this.summaryError = null;
try {
const dateStr = (date || new Date()).toISOString().split('T')[0];
const allMeals = await mealCollection.getAll();
const dayMeals = allMeals.filter((m) => m.date === dateStr);
const totalNutrition = dayMeals.reduce(
(acc, m) => ({
calories: acc.calories + (m.nutrition?.calories || 0),
protein: acc.protein + (m.nutrition?.protein || 0),
carbohydrates: acc.carbohydrates + (m.nutrition?.carbohydrates || 0),
fat: acc.fat + (m.nutrition?.fat || 0),
fiber: acc.fiber + (m.nutrition?.fiber || 0),
sugar: acc.sugar + (m.nutrition?.sugar || 0),
}),
{ calories: 0, protein: 0, carbohydrates: 0, fat: 0, fiber: 0, sugar: 0 }
);
this.dailySummary = {
date: new Date(dateStr),
meals: dayMeals.map(toMealWithNutrition),
totalNutrition,
} as DailySummary;
} catch (err) {
this.summaryError =
err instanceof Error ? err.message : 'Zusammenfassung konnte nicht geladen werden';
} finally {
this.summaryLoading = false;
}
}
async addMeal(mealData: {
date: string;
mealType: string;
inputType: string;
description: string;
confidence: number;
calories: number;
protein: number;
carbohydrates: number;
fat: number;
fiber?: number;
sugar?: number;
}) {
this.error = null;
try {
const newMeal: LocalMeal = {
id: crypto.randomUUID(),
date: mealData.date,
mealType: mealData.mealType as any,
inputType: mealData.inputType as any,
description: mealData.description,
confidence: mealData.confidence,
nutrition: {
calories: mealData.calories,
protein: mealData.protein,
carbohydrates: mealData.carbohydrates,
fat: mealData.fat,
fiber: mealData.fiber || 0,
sugar: mealData.sugar || 0,
},
};
const inserted = await mealCollection.insert(newMeal);
const meal = toMealWithNutrition(inserted);
this.meals = [...this.meals, meal];
await this.fetchDailySummary();
NutriPhiEvents.mealAdded(mealData.mealType, mealData.inputType);
return meal;
} catch (err) {
const message =
err instanceof Error ? err.message : 'Mahlzeit konnte nicht gespeichert werden';
this.error = message;
throw new Error(message);
}
}
async deleteMeal(mealId: string) {
this.deleteError = null;
try {
await mealCollection.delete(mealId);
this.meals = this.meals.filter((m) => m.id !== mealId);
await this.fetchDailySummary();
NutriPhiEvents.mealDeleted();
} catch (err) {
this.deleteError =
err instanceof Error ? err.message : 'Mahlzeit konnte nicht gelöscht werden';
throw new Error(this.deleteError);
}
}
clearErrors() {
this.error = null;
this.summaryError = null;
this.deleteError = null;
}
}
export const mealsStore = new MealsStore();
export const mealsStore = {
addMeal,
deleteMeal,
};

View file

@ -6,7 +6,7 @@
import { QuickInputBar } from '@manacore/shared-ui';
import type { QuickInputItem, CreatePreview } from '@manacore/shared-ui';
import { authStore } from '$lib/stores/auth.svelte';
import { mealsStore } from '$lib/stores/meals.svelte';
import { useAllMeals, searchMeals } from '$lib/data/queries';
import { parseMealInput, formatParsedMealPreview } from '$lib/utils/meal-parser';
import { SessionExpiredBanner, AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
@ -15,11 +15,12 @@
let showGuestWelcome = $state(false);
// Reactive live query for search
const allMeals = useAllMeals();
// QuickInputBar handlers - search recent meals
async function handleSearch(query: string): Promise<QuickInputItem[]> {
const q = query.toLowerCase();
return mealsStore.meals
.filter((m) => m.description?.toLowerCase().includes(q))
return searchMeals(allMeals.value, query)
.slice(0, 10)
.map((meal) => ({
id: meal.id,

View file

@ -1,7 +1,22 @@
<script lang="ts">
import { skillStore } from '$lib/stores/skills.svelte';
import { achievementStore } from '$lib/stores/achievements.svelte';
import {
useAllSkills,
useAllActivities,
useAllAchievements,
computeUserStats,
} from '$lib/data/queries';
import { buildAchievementStatus, getAchievementStats } from '$lib/stores/achievements.svelte';
import { Trophy, Lightning, Target, Fire, Medal } from '@manacore/shared-icons';
// Reactive live queries
const allSkills = useAllSkills();
const allActivities = useAllActivities();
const allAchievementsRaw = useAllAchievements();
const userStats = $derived(computeUserStats(allSkills.value, allActivities.value));
const achievementStats = $derived(
getAchievementStats(buildAchievementStatus(allAchievementsRaw.value))
);
</script>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
@ -14,7 +29,7 @@
<div>
<p class="text-sm text-gray-400">Gesamt-XP</p>
<p class="text-2xl font-bold text-white">
{skillStore.userStats.totalXp.toLocaleString()}
{userStats.totalXp.toLocaleString()}
</p>
</div>
</div>
@ -29,7 +44,7 @@
<div>
<p class="text-sm text-gray-400">Skills</p>
<p class="text-2xl font-bold text-white">
{skillStore.userStats.totalSkills}
{userStats.totalSkills}
</p>
</div>
</div>
@ -42,9 +57,9 @@
<Trophy class="h-6 w-6 text-purple-500" />
</div>
<div>
<p class="text-sm text-gray-400">Höchstes Level</p>
<p class="text-sm text-gray-400">Hochstes Level</p>
<p class="text-2xl font-bold text-white">
{skillStore.userStats.highestLevel}
{userStats.highestLevel}
</p>
</div>
</div>
@ -59,7 +74,7 @@
<div>
<p class="text-sm text-gray-400">Streak</p>
<p class="text-2xl font-bold text-white">
{skillStore.userStats.streakDays} Tage
{userStats.streakDays} Tage
</p>
</div>
</div>
@ -77,8 +92,8 @@
<div>
<p class="text-sm text-gray-400">Achievements</p>
<p class="text-2xl font-bold text-white">
{achievementStore.stats().unlocked}<span class="text-sm font-normal text-gray-500"
>/{achievementStore.stats().total}</span
{achievementStats.unlocked}<span class="text-sm font-normal text-gray-500"
>/{achievementStats.total}</span
>
</p>
</div>

View file

@ -0,0 +1,186 @@
/**
* Reactive Queries & Pure Helpers for SkilltTree
*
* Uses Dexie liveQuery to automatically re-render when IndexedDB changes
* (local writes, sync updates, other tabs). Components call these hooks
* at init time; no manual fetch/refresh needed.
*/
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import {
skillCollection,
activityCollection,
achievementCollection,
type LocalSkill,
type LocalActivity,
type LocalAchievement,
} from './local-store';
import type { Skill, Activity, SkillBranch, UserStats } from '$lib/types';
import { BRANCH_INFO } from '$lib/types';
// ─── Type Converters ───────────────────────────────────────
export function toSkill(local: LocalSkill): Skill {
return {
id: local.id,
name: local.name,
description: local.description,
branch: local.branch,
parentId: local.parentId ?? null,
icon: local.icon,
color: local.color ?? null,
currentXp: local.currentXp,
totalXp: local.totalXp,
level: local.level,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toActivity(local: LocalActivity): Activity {
return {
id: local.id,
skillId: local.skillId,
xpEarned: local.xpEarned,
description: local.description,
duration: local.duration ?? null,
timestamp: local.timestamp,
};
}
// ─── Live Query Hooks (call during component init) ─────────
/** All skills, auto-updates on any change. */
export function useAllSkills() {
return useLiveQueryWithDefault(async () => {
const locals = await skillCollection.getAll();
return locals.map(toSkill);
}, [] as Skill[]);
}
/** All activities, auto-updates on any change. */
export function useAllActivities() {
return useLiveQueryWithDefault(async () => {
const locals = await activityCollection.getAll();
return locals.map(toActivity);
}, [] as Activity[]);
}
/** All achievements (raw local records), auto-updates on any change. */
export function useAllAchievements() {
return useLiveQueryWithDefault(async () => {
return await achievementCollection.getAll();
}, [] as LocalAchievement[]);
}
// ─── Pure Filter/Helper Functions (for $derived) ──────────
/** Group skills by branch. */
export function groupByBranch(skills: Skill[]): Record<SkillBranch, Skill[]> {
const grouped: Record<SkillBranch, Skill[]> = {
intellect: [],
body: [],
creativity: [],
social: [],
practical: [],
mindset: [],
custom: [],
};
for (const skill of skills) {
grouped[skill.branch].push(skill);
}
return grouped;
}
/** Get top N skills by total XP. */
export function getTopSkills(skills: Skill[], n = 5): Skill[] {
return [...skills].sort((a, b) => b.totalXp - a.totalXp).slice(0, n);
}
/** Get recent N activities sorted by timestamp descending. */
export function getRecentActivities(activities: Activity[], n = 10): Activity[] {
return [...activities]
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
.slice(0, n);
}
/** Compute branch-level stats. */
export function computeBranchStats(
skills: Skill[]
): Record<SkillBranch, { count: number; totalXp: number; avgLevel: number }> {
const stats = {} as Record<SkillBranch, { count: number; totalXp: number; avgLevel: number }>;
for (const branch of Object.keys(BRANCH_INFO) as SkillBranch[]) {
const branchSkills = skills.filter((s) => s.branch === branch);
stats[branch] = {
count: branchSkills.length,
totalXp: branchSkills.reduce((sum, s) => sum + s.totalXp, 0),
avgLevel:
branchSkills.length > 0
? branchSkills.reduce((sum, s) => sum + s.level, 0) / branchSkills.length
: 0,
};
}
return stats;
}
/** Calculate activity streak in days. */
export function calculateStreak(activities: Activity[]): number {
if (activities.length === 0) return 0;
const today = new Date();
today.setHours(0, 0, 0, 0);
const sortedDates = activities
.map((a) => {
const d = new Date(a.timestamp);
d.setHours(0, 0, 0, 0);
return d.getTime();
})
.filter((v, i, a) => a.indexOf(v) === i)
.sort((a, b) => b - a);
let streak = 0;
let expectedDate = today.getTime();
for (const date of sortedDates) {
if (date === expectedDate || date === expectedDate - 86400000) {
streak++;
expectedDate = date - 86400000;
} else if (date < expectedDate - 86400000) {
break;
}
}
return streak;
}
/** Compute aggregate user stats from skills and activities. */
export function computeUserStats(skills: Skill[], activities: Activity[]): UserStats {
const sortedActivities = [...activities].sort(
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
);
return {
totalXp: skills.reduce((sum, s) => sum + s.totalXp, 0),
totalSkills: skills.length,
highestLevel: skills.reduce((max, s) => Math.max(max, s.level), 0),
streakDays: calculateStreak(activities),
lastActivityDate: sortedActivities.length > 0 ? sortedActivities[0].timestamp : null,
};
}
/** Filter skills by branch (or return all if 'all'). */
export function filterByBranch(skills: Skill[], branch: SkillBranch | 'all'): Skill[] {
if (branch === 'all') return skills;
return skills.filter((s) => s.branch === branch);
}
/** Find a skill by ID. */
export function getSkillById(skills: Skill[], id: string): Skill | undefined {
return skills.find((s) => s.id === id);
}
/** Get all activities for a specific skill. */
export function getSkillActivities(activities: Activity[], skillId: string): Activity[] {
return activities.filter((a) => a.skillId === skillId);
}

View file

@ -1,8 +1,8 @@
/**
* Achievements Store Local-First with @manacore/local-store
* Achievements Store Write Actions + Unlock Queue
*
* All achievement state stored in IndexedDB via Dexie.js.
* Sync to server happens automatically when authenticated.
* Reads are handled by useLiveQuery hooks in queries.ts.
* This store handles achievement checking logic and the unlock celebration queue.
*/
import type {
@ -16,25 +16,47 @@ import type {
import { ACHIEVEMENT_DEFINITIONS } from '$lib/types';
import { achievementCollection, type LocalAchievement } from '$lib/data/local-store';
// Reactive state
let achievements = $state<AchievementWithStatus[]>([]);
let isLoading = $state(true);
let initialized = $state(false);
// Queue of recently unlocked achievements to show celebrations
let unlockQueue = $state<AchievementUnlockResult[]>([]);
// ─── Derived values ──────────────────────────────────────────
// ─── Derived helpers (pure functions for consumers) ──────────
const unlockedAchievements = $derived(() => {
/** Build achievement status list from stored records and definitions. */
export function buildAchievementStatus(stored: LocalAchievement[]): AchievementWithStatus[] {
if (stored.length === 0) {
return ACHIEVEMENT_DEFINITIONS.map((def) => ({
...def,
unlocked: false,
unlockedAt: null,
progress: 0,
}));
}
return ACHIEVEMENT_DEFINITIONS.map((def) => {
const found = stored.find((s) => s.key === def.id || s.id === def.id);
return {
...def,
unlocked: found?.unlockedAt ? true : false,
unlockedAt: found?.unlockedAt || null,
progress: 0,
};
});
}
export function getUnlockedAchievements(
achievements: AchievementWithStatus[]
): AchievementWithStatus[] {
return achievements.filter((a) => a.unlocked);
});
}
const lockedAchievements = $derived(() => {
export function getLockedAchievements(
achievements: AchievementWithStatus[]
): AchievementWithStatus[] {
return achievements.filter((a) => !a.unlocked);
});
}
const achievementsByCategory = $derived(() => {
export function getAchievementsByCategory(
achievements: AchievementWithStatus[]
): Record<AchievementCategory, AchievementWithStatus[]> {
const grouped: Record<AchievementCategory, AchievementWithStatus[]> = {
xp: [],
skills: [],
@ -48,81 +70,41 @@ const achievementsByCategory = $derived(() => {
grouped[a.category].push(a);
}
return grouped;
});
}
const stats = $derived(() => {
export function getAchievementStats(achievements: AchievementWithStatus[]): {
total: number;
unlocked: number;
} {
return {
total: achievements.length,
unlocked: achievements.filter((a) => a.unlocked).length,
};
});
}
const completionPercentage = $derived(() => {
export function getCompletionPercentage(achievements: AchievementWithStatus[]): number {
if (achievements.length === 0) return 0;
return Math.round((achievements.filter((a) => a.unlocked).length / achievements.length) * 100);
});
}
// ─── Actions ─────────────────────────────────────────────────
async function initialize() {
if (initialized) return;
isLoading = true;
try {
const stored = await achievementCollection.getAll();
if (stored.length === 0) {
// First time: seed from definitions
achievements = ACHIEVEMENT_DEFINITIONS.map((def) => ({
...def,
unlocked: false,
unlockedAt: null,
progress: 0,
}));
// Save each to IndexedDB
for (const a of achievements) {
await achievementCollection.insert({
id: a.id,
key: a.id,
name: a.name,
description: a.description,
icon: a.icon,
unlockedAt: '',
});
}
} else {
// Merge stored data with definitions (in case new achievements were added)
achievements = ACHIEVEMENT_DEFINITIONS.map((def) => {
const found = stored.find((s) => s.key === def.id || s.id === def.id);
return {
...def,
unlocked: found?.unlockedAt ? true : false,
unlockedAt: found?.unlockedAt || null,
progress: 0,
};
async function seedIfEmpty() {
const stored = await achievementCollection.getAll();
if (stored.length === 0) {
for (const def of ACHIEVEMENT_DEFINITIONS) {
await achievementCollection.insert({
id: def.id,
key: def.id,
name: def.name,
description: def.description,
icon: def.icon,
unlockedAt: '',
});
}
initialized = true;
} catch (error) {
console.error('Failed to initialize achievements store:', error);
// Fallback to definitions
achievements = ACHIEVEMENT_DEFINITIONS.map((def) => ({
...def,
unlocked: false,
unlockedAt: null,
progress: 0,
}));
} finally {
isLoading = false;
}
}
async function reinitialize() {
initialized = false;
achievements = [];
unlockQueue = [];
await initialize();
}
/**
* Check achievements locally (offline mode).
* Called after skill/activity changes.
@ -135,6 +117,10 @@ async function checkLocal(context: {
}): Promise<AchievementUnlockResult[]> {
const { skills, activities: allActivities, userStats: stats, lastActivityXp } = context;
// Get current achievements from DB
const stored = await achievementCollection.getAll();
const achievements = buildAchievementStatus(stored);
const uniqueBranches = new Set(skills.map((s) => s.branch).filter((b) => b !== 'custom'));
const mainBranches = ['intellect', 'body', 'creativity', 'social', 'practical', 'mindset'];
@ -161,8 +147,7 @@ async function checkLocal(context: {
const newlyUnlocked: AchievementUnlockResult[] = [];
for (let i = 0; i < achievements.length; i++) {
const a = achievements[i];
for (const a of achievements) {
if (a.unlocked) continue;
const condition = a.condition;
@ -205,23 +190,10 @@ async function checkLocal(context: {
}
if (met) {
const unlocked: AchievementWithStatus = {
...a,
unlocked: true,
unlockedAt: new Date().toISOString(),
progress: condition.threshold,
};
achievements = [...achievements.slice(0, i), unlocked, ...achievements.slice(i + 1)];
await achievementCollection.update(a.id, {
unlockedAt: unlocked.unlockedAt!,
unlockedAt: new Date().toISOString(),
});
newlyUnlocked.push({ achievement: a, xpReward: a.xpReward });
} else {
// Update progress
const updated = { ...a, progress: Math.min(current, condition.threshold) };
if (updated.progress !== a.progress) {
achievements = [...achievements.slice(0, i), updated, ...achievements.slice(i + 1)];
}
}
}
@ -232,31 +204,6 @@ async function checkLocal(context: {
return newlyUnlocked;
}
/**
* Handle achievements returned from server sync.
*/
function handleApiUnlocks(results: AchievementUnlockResult[]) {
if (results.length === 0) return;
for (const result of results) {
const index = achievements.findIndex((a) => a.id === result.achievement.id);
if (index !== -1) {
achievements = [
...achievements.slice(0, index),
{
...achievements[index],
unlocked: true,
unlockedAt: new Date().toISOString(),
progress: achievements[index].condition.threshold,
},
...achievements.slice(index + 1),
];
}
}
unlockQueue = [...unlockQueue, ...results];
}
function popUnlockQueue(): AchievementUnlockResult | null {
if (unlockQueue.length === 0) return null;
const [first, ...rest] = unlockQueue;
@ -265,37 +212,11 @@ function popUnlockQueue(): AchievementUnlockResult | null {
}
export const achievementStore = {
get achievements() {
return achievements;
},
get isLoading() {
return isLoading;
},
get initialized() {
return initialized;
},
get unlockedAchievements() {
return unlockedAchievements;
},
get lockedAchievements() {
return lockedAchievements;
},
get achievementsByCategory() {
return achievementsByCategory;
},
get stats() {
return stats;
},
get completionPercentage() {
return completionPercentage;
},
get unlockQueue() {
return unlockQueue;
},
initialize,
reinitialize,
seedIfEmpty,
checkLocal,
handleApiUnlocks,
popUnlockQueue,
};

View file

@ -1,12 +1,12 @@
/**
* Skills Store Local-First with @manacore/local-store
* Skills Store Write Actions Only
*
* All reads and writes go to IndexedDB (Dexie.js) first.
* When authenticated, changes sync to the server in the background.
* Reads are handled by useLiveQuery hooks in queries.ts.
* This store only exposes mutation actions that write to IndexedDB.
*/
import type { Skill, Activity, UserStats, SkillBranch } from '$lib/types';
import { calculateLevel, createDefaultSkill, createActivity, BRANCH_INFO } from '$lib/types';
import type { Skill, Activity } from '$lib/types';
import { calculateLevel, createDefaultSkill, createActivity } from '$lib/types';
import { SkillTreeEvents } from '@manacore/shared-utils/analytics';
import {
skillCollection,
@ -14,118 +14,9 @@ import {
type LocalSkill,
type LocalActivity,
} from '$lib/data/local-store';
import { achievementStore } from './achievements.svelte';
// Reactive state using Svelte 5 runes
let skills = $state<Skill[]>([]);
let activities = $state<Activity[]>([]);
let userStats = $state<UserStats>({
totalXp: 0,
totalSkills: 0,
highestLevel: 0,
streakDays: 0,
lastActivityDate: null,
});
let isLoading = $state(true);
let initialized = $state(false);
// ─── Converters ──────────────────────────────────────────────
function toSkill(local: LocalSkill): Skill {
return {
id: local.id,
name: local.name,
description: local.description,
branch: local.branch,
parentId: local.parentId ?? null,
icon: local.icon,
color: local.color ?? null,
currentXp: local.currentXp,
totalXp: local.totalXp,
level: local.level,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
function toActivity(local: LocalActivity): Activity {
return {
id: local.id,
skillId: local.skillId,
xpEarned: local.xpEarned,
description: local.description,
duration: local.duration ?? null,
timestamp: local.timestamp,
};
}
// ─── Derived values ──────────────────────────────────────────
const skillsByBranch = $derived(() => {
const grouped: Record<SkillBranch, Skill[]> = {
intellect: [],
body: [],
creativity: [],
social: [],
practical: [],
mindset: [],
custom: [],
};
for (const skill of skills) {
grouped[skill.branch].push(skill);
}
return grouped;
});
const topSkills = $derived(() => {
return [...skills].sort((a, b) => b.totalXp - a.totalXp).slice(0, 5);
});
const recentActivities = $derived(() => {
return [...activities]
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
.slice(0, 10);
});
const branchStats = $derived(() => {
const stats: Record<SkillBranch, { count: number; totalXp: number; avgLevel: number }> =
{} as Record<SkillBranch, { count: number; totalXp: number; avgLevel: number }>;
for (const branch of Object.keys(BRANCH_INFO) as SkillBranch[]) {
const branchSkills = skills.filter((s) => s.branch === branch);
stats[branch] = {
count: branchSkills.length,
totalXp: branchSkills.reduce((sum, s) => sum + s.totalXp, 0),
avgLevel:
branchSkills.length > 0
? branchSkills.reduce((sum, s) => sum + s.level, 0) / branchSkills.length
: 0,
};
}
return stats;
});
// ─── Actions ─────────────────────────────────────────────────
async function initialize() {
if (initialized) return;
isLoading = true;
try {
const [localSkills, localActivities] = await Promise.all([
skillCollection.getAll(),
activityCollection.getAll(),
]);
skills = localSkills.map(toSkill);
activities = localActivities.map(toActivity);
recalculateStats();
initialized = true;
} catch (error) {
console.error('Failed to initialize skills store:', error);
} finally {
isLoading = false;
}
}
async function addSkill(data: Partial<Skill>): Promise<Skill> {
const skill = createDefaultSkill(data);
const localSkill: LocalSkill = {
@ -141,16 +32,11 @@ async function addSkill(data: Partial<Skill>): Promise<Skill> {
level: skill.level,
};
await skillCollection.insert(localSkill);
skills = [...skills, skill];
SkillTreeEvents.skillCreated(data.branch || 'custom');
recalculateStats();
return skill;
}
async function updateSkill(id: string, updates: Partial<Skill>): Promise<void> {
const index = skills.findIndex((s) => s.id === id);
if (index === -1) return;
const localUpdates: Partial<LocalSkill> = {};
if (updates.name !== undefined) localUpdates.name = updates.name;
if (updates.description !== undefined) localUpdates.description = updates.description;
@ -160,9 +46,6 @@ async function updateSkill(id: string, updates: Partial<Skill>): Promise<void> {
if (updates.color !== undefined) localUpdates.color = updates.color;
await skillCollection.update(id, localUpdates);
const updatedSkill = { ...skills[index], ...updates, updatedAt: new Date().toISOString() };
skills = [...skills.slice(0, index), updatedSkill, ...skills.slice(index + 1)];
recalculateStats();
}
async function deleteSkill(id: string): Promise<void> {
@ -172,10 +55,7 @@ async function deleteSkill(id: string): Promise<void> {
await activityCollection.delete(a.id);
}
await skillCollection.delete(id);
skills = skills.filter((s) => s.id !== id);
activities = activities.filter((a) => a.skillId !== id);
SkillTreeEvents.skillDeleted();
recalculateStats();
}
async function addXp(
@ -184,10 +64,10 @@ async function addXp(
description: string,
duration?: number
): Promise<{ leveledUp: boolean; newLevel: number }> {
const index = skills.findIndex((s) => s.id === skillId);
if (index === -1) return { leveledUp: false, newLevel: 0 };
const existing = await skillCollection.getAll({ id: skillId });
const skill = existing.find((s) => s.id === skillId);
if (!skill) return { leveledUp: false, newLevel: 0 };
const skill = skills[index];
const newTotalXp = skill.totalXp + xp;
const newCurrentXp = skill.currentXp + xp;
const newLevel = calculateLevel(newTotalXp);
@ -210,124 +90,15 @@ async function addXp(
};
await activityCollection.insert(localActivity);
const updatedSkill: Skill = {
...skill,
totalXp: newTotalXp,
currentXp: newCurrentXp,
level: newLevel,
updatedAt: new Date().toISOString(),
};
skills = [...skills.slice(0, index), updatedSkill, ...skills.slice(index + 1)];
activities = [...activities, activity];
SkillTreeEvents.xpAdded(xp, leveledUp);
recalculateStats();
return { leveledUp, newLevel };
}
function recalculateStats(): void {
const sortedActivities = [...activities].sort(
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
);
userStats = {
totalXp: skills.reduce((sum, s) => sum + s.totalXp, 0),
totalSkills: skills.length,
highestLevel: skills.reduce((max, s) => Math.max(max, s.level), 0),
streakDays: calculateStreak(activities),
lastActivityDate: sortedActivities.length > 0 ? sortedActivities[0].timestamp : null,
};
}
function calculateStreak(activityList: Activity[]): number {
if (activityList.length === 0) return 0;
const today = new Date();
today.setHours(0, 0, 0, 0);
const sortedDates = activityList
.map((a) => {
const d = new Date(a.timestamp);
d.setHours(0, 0, 0, 0);
return d.getTime();
})
.filter((v, i, a) => a.indexOf(v) === i)
.sort((a, b) => b - a);
let streak = 0;
let expectedDate = today.getTime();
for (const date of sortedDates) {
if (date === expectedDate || date === expectedDate - 86400000) {
streak++;
expectedDate = date - 86400000;
} else if (date < expectedDate - 86400000) {
break;
}
}
return streak;
}
function getSkill(id: string): Skill | undefined {
return skills.find((s) => s.id === id);
}
function getSkillActivities(skillId: string): Activity[] {
return activities.filter((a) => a.skillId === skillId);
}
async function reinitialize() {
initialized = false;
skills = [];
activities = [];
userStats = {
totalXp: 0,
totalSkills: 0,
highestLevel: 0,
streakDays: 0,
lastActivityDate: null,
};
await initialize();
}
// Export store
// Export store (write-only actions)
export const skillStore = {
get skills() {
return skills;
},
get activities() {
return activities;
},
get userStats() {
return userStats;
},
get isLoading() {
return isLoading;
},
get initialized() {
return initialized;
},
get skillsByBranch() {
return skillsByBranch;
},
get topSkills() {
return topSkills;
},
get recentActivities() {
return recentActivities;
},
get branchStats() {
return branchStats;
},
initialize,
reinitialize,
addSkill,
updateSkill,
deleteSkill,
addXp,
getSkill,
getSkillActivities,
};

View file

@ -2,9 +2,8 @@
import '../app.css';
import '$lib/i18n';
import { isLoading as i18nLoading, _ as t } from 'svelte-i18n';
import { skillStore } from '$lib/stores/skills.svelte';
import { achievementStore } from '$lib/stores/achievements.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { achievementStore } from '$lib/stores/achievements.svelte';
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
import { skilltreeOnboarding } from '$lib/stores/app-onboarding.svelte';
import { SessionExpiredBanner, AuthGate } from '@manacore/shared-auth-ui';
@ -18,9 +17,8 @@
if (authStore.isAuthenticated) {
skilltreeStore.startSync(() => authStore.getValidToken());
}
// Load data from IndexedDB into reactive stores
await skillStore.initialize();
await achievementStore.initialize();
// Seed achievement definitions into IndexedDB if first run
await achievementStore.seedIfEmpty();
}
</script>

View file

@ -1,6 +1,16 @@
<script lang="ts">
import { skillStore } from '$lib/stores/skills.svelte';
import { achievementStore } from '$lib/stores/achievements.svelte';
import {
useAllSkills,
useAllActivities,
useAllAchievements,
filterByBranch,
getRecentActivities,
getSkillById,
computeUserStats,
} from '$lib/data/queries';
import { buildAchievementStatus, getAchievementStats } from '$lib/stores/achievements.svelte';
import { BRANCH_INFO } from '$lib/types';
import type { Skill, SkillBranch, AchievementUnlockResult } from '$lib/types';
import SkillCard from '$lib/components/SkillCard.svelte';
@ -22,6 +32,18 @@
Trophy,
} from '@manacore/shared-icons';
// Reactive live queries — auto-update when IndexedDB changes
const allSkills = useAllSkills();
const allActivities = useAllActivities();
const allAchievementsRaw = useAllAchievements();
// Derived values from live queries
const skills = $derived(allSkills.value);
const activities = $derived(allActivities.value);
const achievements = $derived(buildAchievementStatus(allAchievementsRaw.value));
const achievementStats = $derived(getAchievementStats(achievements));
const userStats = $derived(computeUserStats(skills, activities));
// Modal states
let showAddSkillModal = $state(false);
let showAddXpModal = $state(false);
@ -39,10 +61,7 @@
let showAchievementCelebration = $state(false);
let currentAchievementUnlock = $state<AchievementUnlockResult | null>(null);
const filteredSkills = $derived(() => {
if (selectedBranch === 'all') return skillStore.skills;
return skillStore.skills.filter((s) => s.branch === selectedBranch);
});
const filteredSkills = $derived(filterByBranch(skills, selectedBranch));
function openAddXpModal(skill: Skill) {
selectedSkill = skill;
@ -78,15 +97,13 @@
}
async function checkAchievementsLocal(lastActivityXp?: number) {
if (!achievementStore.useApi) {
await achievementStore.checkLocal({
skills: skillStore.skills,
activities: skillStore.activities,
userStats: skillStore.userStats,
lastActivityXp,
});
showNextAchievement();
}
await achievementStore.checkLocal({
skills,
activities,
userStats,
lastActivityXp,
});
showNextAchievement();
}
async function handleAddXp(xp: number, description: string, duration?: number) {
@ -158,11 +175,11 @@
title="Achievements"
>
<Trophy class="h-5 w-5" />
{#if achievementStore.stats().unlocked > 0}
{#if achievementStats.unlocked > 0}
<span
class="absolute -right-0.5 -top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-yellow-500 text-[10px] font-bold text-gray-900"
>
{achievementStore.stats().unlocked}
{achievementStats.unlocked}
</span>
{/if}
</a>
@ -224,10 +241,10 @@
? 'bg-emerald-600 text-white'
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'}"
>
Alle ({skillStore.skills.length})
Alle ({skills.length})
</button>
{#each Object.entries(BRANCH_INFO) as [branch, info]}
{@const count = skillStore.skills.filter((s) => s.branch === branch).length}
{@const count = skills.filter((s) => s.branch === branch).length}
{#if count > 0 || branch !== 'custom'}
<button
onclick={() => (selectedBranch = branch as SkillBranch)}
@ -244,7 +261,7 @@
</div>
<!-- Skills Grid -->
{#if filteredSkills().length === 0}
{#if filteredSkills.length === 0}
<div class="mt-16 text-center">
<div
class="mx-auto mb-6 flex h-24 w-24 items-center justify-center rounded-full bg-gray-800"
@ -263,7 +280,7 @@
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each filteredSkills() as skill (skill.id)}
{#each filteredSkills as skill (skill.id)}
<SkillCard
{skill}
onAddXp={() => openAddXpModal(skill)}
@ -275,15 +292,15 @@
{/if}
<!-- Recent Activity -->
{#if skillStore.recentActivities().length > 0}
{#if getRecentActivities(activities).length > 0}
<div class="mt-12">
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-white">
<Lightning class="h-5 w-5 text-yellow-500" />
Letzte Aktivitäten
</h2>
<div class="space-y-2">
{#each skillStore.recentActivities().slice(0, 5) as activity}
{@const skill = skillStore.getSkill(activity.skillId)}
{#each getRecentActivities(activities).slice(0, 5) as activity}
{@const skill = getSkillById(skills, activity.skillId)}
{#if skill}
<div class="flex items-center justify-between rounded-lg bg-gray-800/50 px-4 py-3">
<div class="flex items-center gap-3">

View file

@ -1,15 +1,26 @@
<script lang="ts">
import { achievementStore } from '$lib/stores/achievements.svelte';
import { useAllAchievements } from '$lib/data/queries';
import {
buildAchievementStatus,
getAchievementStats,
getCompletionPercentage,
} from '$lib/stores/achievements.svelte';
import { ACHIEVEMENT_CATEGORY_INFO, RARITY_INFO } from '$lib/types';
import type { AchievementCategory } from '$lib/types';
import AchievementCard from '$lib/components/AchievementCard.svelte';
import { ArrowLeft, Trophy, Star } from '@manacore/shared-icons';
// Reactive live query
const allAchievementsRaw = useAllAchievements();
const achievements = $derived(buildAchievementStatus(allAchievementsRaw.value));
const stats = $derived(getAchievementStats(achievements));
const completion = $derived(getCompletionPercentage(achievements));
let selectedCategory = $state<AchievementCategory | 'all'>('all');
let showOnlyUnlocked = $state(false);
const filteredAchievements = $derived(() => {
let list = achievementStore.achievements;
let list = achievements;
if (selectedCategory !== 'all') {
list = list.filter((a) => a.category === selectedCategory);
}
@ -46,7 +57,7 @@
<div class="flex items-center gap-2 rounded-full bg-yellow-500/10 px-4 py-2">
<Trophy class="h-4 w-4 text-yellow-400" />
<span class="font-semibold text-yellow-400">
{achievementStore.stats().unlocked} / {achievementStore.stats().total}
{stats.unlocked} / {stats.total}
</span>
</div>
</div>
@ -59,22 +70,18 @@
<div class="mb-8 rounded-xl border border-gray-700 bg-gray-800/50 p-6">
<div class="flex items-center justify-between mb-3">
<h2 class="text-lg font-semibold text-white">Fortschritt</h2>
<span class="text-2xl font-bold text-yellow-400"
>{achievementStore.completionPercentage()}%</span
>
<span class="text-2xl font-bold text-yellow-400">{completion}%</span>
</div>
<div class="h-3 overflow-hidden rounded-full bg-gray-700">
<div
class="h-full rounded-full bg-gradient-to-r from-yellow-500 to-yellow-400 transition-all duration-500"
style="width: {achievementStore.completionPercentage()}%"
style="width: {completion}%"
></div>
</div>
<div class="mt-3 flex flex-wrap gap-4 text-sm">
{#each Object.entries(RARITY_INFO) as [rarity, info]}
{@const count = achievementStore.achievements.filter(
(a) => a.rarity === rarity && a.unlocked
).length}
{@const total = achievementStore.achievements.filter((a) => a.rarity === rarity).length}
{@const count = achievements.filter((a) => a.rarity === rarity && a.unlocked).length}
{@const total = achievements.filter((a) => a.rarity === rarity).length}
<span class="flex items-center gap-1.5 {info.color}">
<Star class="h-3 w-3" />
{info.name}: {count}/{total}
@ -92,10 +99,10 @@
? 'bg-yellow-500 text-gray-900'
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'}"
>
Alle ({achievementStore.achievements.length})
Alle ({achievements.length})
</button>
{#each categoryEntries as [category, info]}
{@const count = achievementStore.achievements.filter((a) => a.category === category).length}
{@const count = achievements.filter((a) => a.category === category).length}
<button
onclick={() => (selectedCategory = category)}
class="rounded-full px-4 py-2 text-sm font-medium transition-colors {selectedCategory ===

View file

@ -1,9 +1,13 @@
<script lang="ts">
import { skillStore } from '$lib/stores/skills.svelte';
import { useAllSkills } from '$lib/data/queries';
import { BRANCH_INFO, LEVEL_NAMES } from '$lib/types';
import type { SkillBranch } from '$lib/types';
import { ArrowLeft, Star } from '@manacore/shared-icons';
// Reactive live query
const allSkills = useAllSkills();
const skills = $derived(allSkills.value);
// Group skills by branch for radial layout
const branches = Object.keys(BRANCH_INFO) as SkillBranch[];
@ -74,7 +78,7 @@
</header>
<main class="p-4">
{#if skillStore.skills.length === 0}
{#if skills.length === 0}
<div class="mt-16 text-center">
<p class="text-gray-400">Noch keine Skills vorhanden. Erstelle zuerst einige Skills!</p>
<a
@ -88,7 +92,7 @@
<!-- Legend -->
<div class="mb-6 flex flex-wrap justify-center gap-4">
{#each Object.entries(BRANCH_INFO) as [branch, info]}
{@const count = skillStore.skills.filter((s) => s.branch === branch).length}
{@const count = skills.filter((s) => s.branch === branch).length}
{#if count > 0}
<div class="flex items-center gap-2 rounded-full bg-gray-800 px-3 py-1.5 text-sm">
<span class="h-3 w-3 rounded-full" style="background-color: {info.color}"></span>
@ -145,7 +149,7 @@
<!-- Branch lines and labels -->
{#each branches as branch, i}
{@const pos = getBranchPosition(i, branches.length)}
{@const branchSkills = skillStore.skills.filter((s) => s.branch === branch)}
{@const branchSkills = skills.filter((s) => s.branch === branch)}
{#if branchSkills.length > 0}
<!-- Line from center to branch -->
<line

View file

@ -2,10 +2,10 @@
FROM golang:1.25-alpine AS builder
WORKDIR /app
COPY services/mana-api-gateway-go/go.mod services/mana-api-gateway-go/go.sum ./
COPY services/mana-api-gateway/go.mod services/mana-api-gateway/go.sum ./
RUN go mod download
COPY services/mana-api-gateway-go/ .
COPY services/mana-api-gateway/ .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /mana-api-gateway ./cmd/server
# Runtime stage

View file

@ -3,6 +3,7 @@ module github.com/manacore/mana-api-gateway
go 1.25.0
require (
github.com/manacore/shared-go v0.0.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/jackc/pgx/v5 v5.9.1
github.com/redis/go-redis/v9 v9.18.0
@ -19,3 +20,5 @@ require (
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
)
replace github.com/manacore/shared-go => ../../packages/shared-go

View file

@ -1,9 +1,9 @@
package config
import (
"os"
"strconv"
"strings"
"github.com/manacore/shared-go/envutil"
)
type Config struct {
@ -30,64 +30,44 @@ type Config struct {
KeyPrefixTest string
// Defaults
DefaultRateLimit int
DefaultRateLimit int
DefaultMonthlyCredits int
CORSOrigins []string
}
func Load() *Config {
port, _ := strconv.Atoi(getEnv("PORT", "3030"))
redisPort, _ := strconv.Atoi(getEnv("REDIS_PORT", "6379"))
defaultRL, _ := strconv.Atoi(getEnv("DEFAULT_RATE_LIMIT", "10"))
defaultCredits, _ := strconv.Atoi(getEnv("DEFAULT_MONTHLY_CREDITS", "100"))
// Parse admin IDs (trim whitespace)
var adminIDs []string
if ids := os.Getenv("ADMIN_USER_IDS"); ids != "" {
for _, id := range strings.Split(ids, ",") {
id = strings.TrimSpace(id)
if id != "" {
adminIDs = append(adminIDs, id)
}
for _, id := range envutil.GetSlice("ADMIN_USER_IDS", nil) {
id = strings.TrimSpace(id)
if id != "" {
adminIDs = append(adminIDs, id)
}
}
var origins []string
if o := os.Getenv("CORS_ORIGINS"); o != "" {
for _, origin := range strings.Split(o, ",") {
origin = strings.TrimSpace(origin)
if origin != "" {
origins = append(origins, origin)
}
}
}
if len(origins) == 0 {
origins = []string{"http://localhost:3000", "http://localhost:5173"}
// Parse CORS origins (trim whitespace)
origins := envutil.GetSlice("CORS_ORIGINS", []string{"http://localhost:3000", "http://localhost:5173"})
for i, o := range origins {
origins[i] = strings.TrimSpace(o)
}
return &Config{
Port: port,
DatabaseURL: getEnv("DATABASE_URL", "postgresql://manacore:devpassword@localhost:5432/manacore"),
RedisHost: getEnv("REDIS_HOST", "localhost"),
RedisPort: redisPort,
RedisPassword: getEnv("REDIS_PASSWORD", ""),
RedisPrefix: getEnv("REDIS_PREFIX", "api-gateway:"),
SearchURL: getEnv("SEARCH_SERVICE_URL", "http://localhost:3021"),
STTURL: getEnv("STT_SERVICE_URL", "http://localhost:3020"),
TTSURL: getEnv("TTS_SERVICE_URL", "http://localhost:3022"),
AuthURL: getEnv("MANA_CORE_AUTH_URL", "http://localhost:3001"),
Port: envutil.GetInt("PORT", 3030),
DatabaseURL: envutil.Get("DATABASE_URL", "postgresql://manacore:devpassword@localhost:5432/manacore"),
RedisHost: envutil.Get("REDIS_HOST", "localhost"),
RedisPort: envutil.GetInt("REDIS_PORT", 6379),
RedisPassword: envutil.Get("REDIS_PASSWORD", ""),
RedisPrefix: envutil.Get("REDIS_PREFIX", "api-gateway:"),
SearchURL: envutil.Get("SEARCH_SERVICE_URL", "http://localhost:3021"),
STTURL: envutil.Get("STT_SERVICE_URL", "http://localhost:3020"),
TTSURL: envutil.Get("TTS_SERVICE_URL", "http://localhost:3022"),
AuthURL: envutil.Get("MANA_CORE_AUTH_URL", "http://localhost:3001"),
AdminUserIDs: adminIDs,
KeyPrefixLive: getEnv("API_KEY_PREFIX_LIVE", "sk_live_"),
KeyPrefixTest: getEnv("API_KEY_PREFIX_TEST", "sk_test_"),
DefaultRateLimit: defaultRL,
DefaultMonthlyCredits: defaultCredits,
KeyPrefixLive: envutil.Get("API_KEY_PREFIX_LIVE", "sk_live_"),
KeyPrefixTest: envutil.Get("API_KEY_PREFIX_TEST", "sk_test_"),
DefaultRateLimit: envutil.GetInt("DEFAULT_RATE_LIMIT", 10),
DefaultMonthlyCredits: envutil.GetInt("DEFAULT_MONTHLY_CREDITS", 100),
CORSOrigins: origins,
}
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

View file

@ -3,6 +3,8 @@ package handler
import (
"encoding/json"
"net/http"
"github.com/manacore/shared-go/httputil"
"time"
"github.com/manacore/mana-api-gateway/internal/middleware"
@ -24,7 +26,7 @@ func NewApiKeysHandler(apiKeySvc *service.ApiKeyService, usageSvc *service.Usage
func (h *ApiKeysHandler) CreateKey(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
if userID == "" {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
httputil.WriteJSON(w, http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
return
}
@ -35,12 +37,12 @@ func (h *ApiKeysHandler) CreateKey(w http.ResponseWriter, r *http.Request) {
IsTest bool `json:"isTest"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
httputil.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if body.Name == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"})
httputil.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"})
return
}
@ -50,11 +52,11 @@ func (h *ApiKeysHandler) CreateKey(w http.ResponseWriter, r *http.Request) {
rawKey, apiKey, err := h.apiKeyService.Create(r.Context(), userID, body.Name, body.Description, body.Tier, body.IsTest)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create key"})
httputil.WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create key"})
return
}
writeJSON(w, http.StatusCreated, map[string]any{
httputil.WriteJSON(w, http.StatusCreated, map[string]any{
"key": rawKey,
"apiKey": apiKey,
})
@ -64,13 +66,13 @@ func (h *ApiKeysHandler) CreateKey(w http.ResponseWriter, r *http.Request) {
func (h *ApiKeysHandler) ListKeys(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
if userID == "" {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
httputil.WriteJSON(w, http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
return
}
keys, err := h.apiKeyService.ListByUser(r.Context(), userID)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list keys"})
httputil.WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list keys"})
return
}
@ -78,7 +80,7 @@ func (h *ApiKeysHandler) ListKeys(w http.ResponseWriter, r *http.Request) {
keys = []service.ApiKey{}
}
writeJSON(w, http.StatusOK, keys)
httputil.WriteJSON(w, http.StatusOK, keys)
}
// DeleteKey handles DELETE /api-keys/{id}
@ -87,11 +89,11 @@ func (h *ApiKeysHandler) DeleteKey(w http.ResponseWriter, r *http.Request) {
keyID := r.PathValue("id")
if err := h.apiKeyService.Delete(r.Context(), keyID, userID); err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "key not found"})
httputil.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "key not found"})
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "key deleted"})
httputil.WriteJSON(w, http.StatusOK, map[string]string{"message": "key deleted"})
}
// GetUsage handles GET /api-keys/{id}/usage
@ -100,7 +102,7 @@ func (h *ApiKeysHandler) GetUsage(w http.ResponseWriter, r *http.Request) {
usage, err := h.usageService.GetDailyUsage(r.Context(), keyID, 30)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to get usage"})
httputil.WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to get usage"})
return
}
@ -108,7 +110,7 @@ func (h *ApiKeysHandler) GetUsage(w http.ResponseWriter, r *http.Request) {
usage = []service.DailyUsage{}
}
writeJSON(w, http.StatusOK, usage)
httputil.WriteJSON(w, http.StatusOK, usage)
}
// GetUsageSummary handles GET /api-keys/{id}/usage/summary
@ -118,15 +120,10 @@ func (h *ApiKeysHandler) GetUsageSummary(w http.ResponseWriter, r *http.Request)
summary, err := h.usageService.GetSummary(r.Context(), keyID, since)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to get summary"})
httputil.WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to get summary"})
return
}
writeJSON(w, http.StatusOK, summary)
httputil.WriteJSON(w, http.StatusOK, summary)
}
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}

View file

@ -3,6 +3,8 @@ package handler
import (
"fmt"
"net/http"
"github.com/manacore/shared-go/httputil"
"time"
"github.com/jackc/pgx/v5/pgxpool"
@ -40,7 +42,7 @@ func (h *HealthHandler) Health(w http.ResponseWriter, r *http.Request) {
status = "degraded"
}
writeJSON(w, http.StatusOK, map[string]any{
httputil.WriteJSON(w, http.StatusOK, map[string]any{
"status": status,
"service": "mana-api-gateway",
"timestamp": time.Now().UTC().Format(time.RFC3339),

View file

@ -2,10 +2,10 @@
FROM golang:1.25-alpine AS builder
WORKDIR /app
COPY services/mana-crawler-go/go.mod services/mana-crawler-go/go.sum ./
COPY services/mana-crawler/go.mod services/mana-crawler/go.sum ./
RUN go mod download
COPY services/mana-crawler-go/ .
COPY services/mana-crawler/ .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /mana-crawler ./cmd/server
# Runtime stage

View file

@ -3,6 +3,7 @@ module github.com/manacore/mana-crawler
go 1.25.0
require (
github.com/manacore/shared-go v0.0.0
github.com/PuerkitoBio/goquery v1.12.0
github.com/jackc/pgx/v5 v5.9.1
github.com/rs/cors v1.11.1
@ -18,3 +19,5 @@ require (
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.35.0 // indirect
)
replace github.com/manacore/shared-go => ../../packages/shared-go

View file

@ -1,9 +1,9 @@
package config
import (
"os"
"strconv"
"strings"
"github.com/manacore/shared-go/envutil"
)
type Config struct {
@ -14,51 +14,31 @@ type Config struct {
RedisPort int
RedisPassword string
UserAgent string
UserAgent string
DefaultRateLimit float64
DefaultMaxDepth int
DefaultMaxPages int
Timeout int // ms
Concurrency int
DefaultMaxDepth int
DefaultMaxPages int
Timeout int // ms
Concurrency int
CORSOrigins []string
}
func Load() *Config {
port, _ := strconv.Atoi(getEnv("PORT", "3023"))
redisPort, _ := strconv.Atoi(getEnv("REDIS_PORT", "6379"))
rateLimit, _ := strconv.ParseFloat(getEnv("CRAWLER_DEFAULT_RATE_LIMIT", "2"), 64)
maxDepth, _ := strconv.Atoi(getEnv("CRAWLER_DEFAULT_MAX_DEPTH", "3"))
maxPages, _ := strconv.Atoi(getEnv("CRAWLER_DEFAULT_MAX_PAGES", "100"))
timeout, _ := strconv.Atoi(getEnv("CRAWLER_TIMEOUT", "30000"))
concurrency, _ := strconv.Atoi(getEnv("QUEUE_CONCURRENCY", "5"))
var origins []string
if o := os.Getenv("CORS_ORIGINS"); o != "" {
origins = strings.Split(o, ",")
} else {
origins = []string{"http://localhost:3000", "http://localhost:5173"}
}
rateLimit, _ := strconv.ParseFloat(envutil.Get("CRAWLER_DEFAULT_RATE_LIMIT", "2"), 64)
return &Config{
Port: port,
DatabaseURL: getEnv("DATABASE_URL", "postgresql://manacore:devpassword@localhost:5432/manacore"),
RedisHost: getEnv("REDIS_HOST", "localhost"),
RedisPort: redisPort,
RedisPassword: getEnv("REDIS_PASSWORD", ""),
UserAgent: getEnv("CRAWLER_USER_AGENT", "ManaCoreCrawler/1.0 (+https://manacore.io/bot)"),
Port: envutil.GetInt("PORT", 3023),
DatabaseURL: envutil.Get("DATABASE_URL", "postgresql://manacore:devpassword@localhost:5432/manacore"),
RedisHost: envutil.Get("REDIS_HOST", "localhost"),
RedisPort: envutil.GetInt("REDIS_PORT", 6379),
RedisPassword: envutil.Get("REDIS_PASSWORD", ""),
UserAgent: envutil.Get("CRAWLER_USER_AGENT", "ManaCoreCrawler/1.0 (+https://manacore.io/bot)"),
DefaultRateLimit: rateLimit,
DefaultMaxDepth: maxDepth,
DefaultMaxPages: maxPages,
Timeout: timeout,
Concurrency: concurrency,
CORSOrigins: origins,
DefaultMaxDepth: envutil.GetInt("CRAWLER_DEFAULT_MAX_DEPTH", 3),
DefaultMaxPages: envutil.GetInt("CRAWLER_DEFAULT_MAX_PAGES", 100),
Timeout: envutil.GetInt("CRAWLER_TIMEOUT", 30000),
Concurrency: envutil.GetInt("QUEUE_CONCURRENCY", 5),
CORSOrigins: envutil.GetSlice("CORS_ORIGINS", []string{"http://localhost:3000", "http://localhost:5173"}),
}
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

View file

@ -6,6 +6,8 @@ import (
"fmt"
"log/slog"
"net/http"
"github.com/manacore/shared-go/httputil"
"net/url"
"strconv"
"time"
@ -33,18 +35,18 @@ func (h *Handler) StartCrawl(w http.ResponseWriter, r *http.Request) {
WebhookURL string `json:"webhookUrl"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
httputil.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
return
}
if body.StartURL == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "startUrl is required"})
httputil.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "startUrl is required"})
return
}
parsed, err := url.Parse(body.StartURL)
if err != nil || parsed.Host == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid URL"})
httputil.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid URL"})
return
}
@ -86,17 +88,17 @@ func (h *Handler) StartCrawl(w http.ResponseWriter, r *http.Request) {
string(configJSON), fmt.Sprintf(`{"format":"%s"}`, cfg.OutputFormat)).Scan(&jobID)
if err != nil {
slog.Error("create job failed", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create job"})
httputil.WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create job"})
return
}
// Start crawl (use background context so it outlives the HTTP request)
if err := h.crawler.StartJob(context.Background(), jobID, body.StartURL, cfg); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to start crawl"})
httputil.WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to start crawl"})
return
}
writeJSON(w, http.StatusCreated, map[string]any{
httputil.WriteJSON(w, http.StatusCreated, map[string]any{
"jobId": jobID,
"status": "running",
"startUrl": body.StartURL,
@ -126,11 +128,11 @@ func (h *Handler) GetJob(w http.ResponseWriter, r *http.Request) {
FROM crawler.crawl_jobs WHERE id = $1
`, jobID).Scan(&job.ID, &job.StartURL, &job.Domain, &job.Status, &job.Progress, &job.Error, &job.StartedAt, &job.CompletedAt, &job.CreatedAt)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "job not found"})
httputil.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "job not found"})
return
}
writeJSON(w, http.StatusOK, job)
httputil.WriteJSON(w, http.StatusOK, job)
}
// GetJobResults handles GET /api/v1/crawl/{jobId}/results
@ -155,7 +157,7 @@ func (h *Handler) GetJobResults(w http.ResponseWriter, r *http.Request) {
FROM crawler.crawl_results WHERE job_id = $1 ORDER BY created_at LIMIT $2 OFFSET $3
`, jobID, limit, offset)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "query failed"})
httputil.WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "query failed"})
return
}
defer rows.Close()
@ -180,7 +182,7 @@ func (h *Handler) GetJobResults(w http.ResponseWriter, r *http.Request) {
results = []map[string]any{}
}
writeJSON(w, http.StatusOK, map[string]any{
httputil.WriteJSON(w, http.StatusOK, map[string]any{
"results": results,
"pagination": map[string]any{
"page": page, "limit": limit, "total": total,
@ -218,7 +220,7 @@ func (h *Handler) ListJobs(w http.ResponseWriter, r *http.Request) {
rows, err := h.pool.Query(r.Context(), query, args...)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "query failed"})
httputil.WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "query failed"})
return
}
defer rows.Close()
@ -237,7 +239,7 @@ func (h *Handler) ListJobs(w http.ResponseWriter, r *http.Request) {
jobs = []map[string]any{}
}
writeJSON(w, http.StatusOK, map[string]any{
httputil.WriteJSON(w, http.StatusOK, map[string]any{
"results": jobs,
"pagination": map[string]any{
"page": page, "limit": limit, "total": total,
@ -263,7 +265,7 @@ func (h *Handler) Health(w http.ResponseWriter, r *http.Request) {
if dbOK != "ok" {
status = "degraded"
}
writeJSON(w, http.StatusOK, map[string]any{
httputil.WriteJSON(w, http.StatusOK, map[string]any{
"status": status, "service": "mana-crawler", "database": dbOK,
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
@ -284,8 +286,3 @@ func (h *Handler) Metrics(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "mana_crawler_jobs{status=\"failed\"} %d\n", failed)
}
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}

View file

@ -1,10 +1,10 @@
FROM golang:1.25-alpine AS builder
WORKDIR /app
COPY services/mana-notify-go/go.mod services/mana-notify-go/go.sum ./
COPY services/mana-notify/go.mod services/mana-notify/go.sum ./
RUN go mod download
COPY services/mana-notify-go/ .
COPY services/mana-notify/ .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /mana-notify ./cmd/server
FROM alpine:3.21

View file

@ -1,10 +1,10 @@
FROM golang:1.25-alpine AS builder
WORKDIR /app
COPY services/mana-search-go/go.mod services/mana-search-go/go.sum ./
COPY services/mana-search/go.mod services/mana-search/go.sum ./
RUN go mod download
COPY services/mana-search-go/ .
COPY services/mana-search/ .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /mana-search ./cmd/server
FROM alpine:3.21