rename(taktik): rebrand to Times

Rename taktik → times across the entire app: package names (@taktik →
@times), appId, localStorage keys, export filenames, type names
(TaktikSettings → TimesSettings), monorepo scripts, shared-branding,
mana-auth trustedOrigins, docker-compose, and documentation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-30 15:44:18 +02:00
parent 1eb370eaaa
commit c33339b0cf
92 changed files with 970 additions and 1263 deletions

View file

@ -54,7 +54,7 @@ For comprehensive guidelines on code patterns and conventions, see the `.claude/
| **citycorners** | City guide for Konstanz | Web, Landing |
| **inventar** | Inventory management | Web |
| **traces** | City exploration | Backend, Mobile |
| **taktik** | Time tracking | Web |
| **times** | Time tracking | Web |
| **uload** | URL shortener & link management | Server, Web, Landing |
| **news** | AI news reader & personal library | Server, Web, Landing |
| **wisekeep** | AI transcription & wisdom library | Server, Web, Landing |
@ -585,7 +585,7 @@ Logged in: App → IndexedDB → UI → SyncEngine → mana-sync (Go) → Postg
| Photos | albums, albumItems, favorites, tags, photoTags | Done |
| SkilltTree | skills, activities, achievements | Done |
| CityCorners | locations, favorites | Done |
| Taktik | clients, projects, timeEntries, tags, templates, settings | Done |
| Times | clients, projects, timeEntries, tags, templates, settings | Done |
| uLoad | links, tags, folders, linkTags | Done |
| Calc | calculations, savedFormulas | Done |
| ManaCore | userSettings, dashboardConfigs | Done |

View file

@ -1,10 +1,10 @@
---
title: 'Taktik: Production Readiness Audit'
title: 'Times: Production Readiness Audit'
description: 'Zeiterfassung mit Live-Timer, Projekten, Kunden, Reports, CSV-Export, Templates und Abrechnungsraten - local-first mit umfassender Dokumentation und solider Testabdeckung'
date: 2026-03-30
app: 'taktik'
app: 'times'
author: 'Claude Code'
tags: ['audit', 'taktik', 'production-readiness', 'beta']
tags: ['audit', 'times', 'production-readiness', 'beta']
score: 55
scores:
backend: 5
@ -39,7 +39,7 @@ stats:
## Zusammenfassung
Taktik ist eine **vollwertige Zeiterfassung** mit Live-Timer, Projekt-/Kunden-Management, Reports mit Charts, CSV-Export und konfigurierbaren Abrechnungsraten. Local-first mit 6 Dexie-Collections, 4 Testdateien und umfassender CLAUDE.md. Feature-komplett für den Produktiveinsatz.
Times ist eine **vollwertige Zeiterfassung** mit Live-Timer, Projekt-/Kunden-Management, Reports mit Charts, CSV-Export und konfigurierbaren Abrechnungsraten. Local-first mit 6 Dexie-Collections, 4 Testdateien und umfassender CLAUDE.md. Feature-komplett für den Produktiveinsatz.
## Backend (5/100)

View file

@ -1,14 +0,0 @@
{
"name": "taktik",
"version": "1.0.0",
"private": true,
"description": "Taktik - Zeiterfassung & Timetracking",
"scripts": {
"dev": "pnpm --filter @taktik/web dev",
"dev:web": "pnpm --filter @taktik/web dev"
},
"devDependencies": {
"typescript": "^5.9.3"
},
"packageManager": "pnpm@9.15.0"
}

View file

@ -1,4 +1,4 @@
# Taktik
# Times
Zeiterfassung & Timetracking - Dein Arbeitsrhythmus, messbar gemacht.
@ -6,7 +6,7 @@ Zeiterfassung & Timetracking - Dein Arbeitsrhythmus, messbar gemacht.
## Project Overview
Taktik is a professional time tracking app with timer, manual entry, projects, clients, reports, templates, and guild (team) integration. Built local-first for offline capability and instant UI.
Times is a professional time tracking app with timer, manual entry, projects, clients, reports, templates, and guild (team) integration. Built local-first for offline capability and instant UI.
### Tech Stack
@ -24,16 +24,16 @@ Taktik is a professional time tracking app with timer, manual entry, projects, c
```bash
# From monorepo root
pnpm dev:taktik:web # Start web app on port 5197
pnpm dev:taktik:full # Start with auth + sync server
pnpm dev:times:web # Start web app on port 5197
pnpm dev:times:full # Start with auth + sync server
# Tests
pnpm --filter @taktik/web test # Run all tests
pnpm --filter @taktik/web test:unit # Run in watch mode
pnpm --filter @times/web test # Run all tests
pnpm --filter @times/web test:unit # Run in watch mode
# Type checking
pnpm --filter @taktik/web type-check
pnpm --filter @taktik/shared type-check
pnpm --filter @times/web type-check
pnpm --filter @times/shared type-check
```
## Key Features
@ -104,7 +104,7 @@ pnpm --filter @taktik/shared type-check
## Project Structure
```
apps/taktik/
apps/times/
├── apps/
│ └── web/ # SvelteKit web client (port 5197)
│ ├── src/
@ -160,7 +160,7 @@ apps/taktik/
│ │ └── version.ts
│ └── static/
├── packages/
│ └── shared/ # @taktik/shared
│ └── shared/ # @times/shared
│ └── src/
│ ├── types/index.ts # All TypeScript types
│ ├── constants/index.ts # Currencies, colors, defaults

View file

@ -9,15 +9,15 @@ ARG PUBLIC_MANA_CORE_AUTH_URL=http://mana-core-auth:3001
ENV PUBLIC_MANA_CORE_AUTH_URL=$PUBLIC_MANA_CORE_AUTH_URL
# Copy app-specific packages
COPY apps/taktik/packages/shared ./apps/taktik/packages/shared
COPY apps/taktik/apps/web ./apps/taktik/apps/web
COPY apps/times/packages/shared ./apps/times/packages/shared
COPY apps/times/apps/web ./apps/times/apps/web
# Install app-specific dependencies
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
pnpm install --no-frozen-lockfile --ignore-scripts
# Build the web app
WORKDIR /app/apps/taktik/apps/web
WORKDIR /app/apps/times/apps/web
RUN pnpm exec svelte-kit sync
RUN NODE_OPTIONS="--max-old-space-size=4096" pnpm build
@ -25,17 +25,17 @@ RUN NODE_OPTIONS="--max-old-space-size=4096" pnpm build
FROM node:20-alpine AS production
# Keep same directory structure as builder so pnpm symlinks resolve correctly
WORKDIR /app/apps/taktik/apps/web
WORKDIR /app/apps/times/apps/web
# Copy the pnpm store that symlinks point to (at /app/node_modules/.pnpm)
COPY --from=builder /app/node_modules/.pnpm /app/node_modules/.pnpm
# Copy the app's node_modules (contains symlinks to the pnpm store)
COPY --from=builder /app/apps/taktik/apps/web/node_modules ./node_modules
COPY --from=builder /app/apps/times/apps/web/node_modules ./node_modules
# Copy built application
COPY --from=builder /app/apps/taktik/apps/web/build ./build
COPY --from=builder /app/apps/taktik/apps/web/package.json ./
COPY --from=builder /app/apps/times/apps/web/build ./build
COPY --from=builder /app/apps/times/apps/web/package.json ./
# Expose port
EXPOSE 5027

View file

@ -1,5 +1,5 @@
{
"name": "@taktik/web",
"name": "@times/web",
"version": "1.0.0",
"private": true,
"scripts": {
@ -44,7 +44,7 @@
"@manacore/shared-types": "workspace:*",
"@manacore/shared-ui": "workspace:*",
"@manacore/shared-utils": "workspace:*",
"@taktik/shared": "workspace:*",
"@times/shared": "workspace:*",
"date-fns": "^4.1.0",
"svelte-i18n": "^4.0.1"
},

View file

@ -9,7 +9,7 @@
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/apple-touch-icon.png" />
<title>Taktik</title>
<title>Times</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">

View file

@ -2,7 +2,7 @@
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import { timeEntryCollection } from '$lib/data/local-store';
import type { Project, Client } from '@taktik/shared';
import type { Project, Client } from '@times/shared';
let {
visible = false,

View file

@ -3,7 +3,7 @@
import { _ } from 'svelte-i18n';
import { timeEntryCollection } from '$lib/data/local-store';
import { formatDurationCompact } from '$lib/data/queries';
import type { TimeEntry, Project, Client } from '@taktik/shared';
import type { TimeEntry, Project, Client } from '@times/shared';
import ConfirmDialog from './ConfirmDialog.svelte';
let {

View file

@ -2,7 +2,7 @@
import { _ } from 'svelte-i18n';
import EntryItem from './EntryItem.svelte';
import { groupEntriesByDate, getTotalDuration, formatDurationCompact } from '$lib/data/queries';
import type { TimeEntry } from '@taktik/shared';
import type { TimeEntry } from '@times/shared';
let { entries }: { entries: TimeEntry[] } = $props();

View file

@ -2,7 +2,7 @@
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import { timerStore } from '$lib/stores/timer.svelte';
import type { TimeEntry, Project } from '@taktik/shared';
import type { TimeEntry, Project } from '@times/shared';
const allTimeEntries = getContext<{ value: TimeEntry[] }>('timeEntries');
const allProjects = getContext<{ value: Project[] }>('projects');

View file

@ -3,7 +3,7 @@
import { _ } from 'svelte-i18n';
import { timerStore } from '$lib/stores/timer.svelte';
import { formatDuration } from '$lib/data/queries';
import type { Project, Client } from '@taktik/shared';
import type { Project, Client } from '@times/shared';
const allProjects = getContext<{ value: Project[] }>('projects');
const allClients = getContext<{ value: Client[] }>('clients');

View file

@ -3,7 +3,7 @@
import { _ } from 'svelte-i18n';
import { timerStore } from '$lib/stores/timer.svelte';
import { formatDuration } from '$lib/data/queries';
import type { Project } from '@taktik/shared';
import type { Project } from '@times/shared';
const allProjects = getContext<{ value: Project[] }>('projects');

View file

@ -1,5 +1,5 @@
/**
* Guest seed data for the Taktik app.
* Guest seed data for the Times app.
*
* Provides demo clients, projects, and time entries for the guest experience.
*/

View file

@ -1,5 +1,5 @@
/**
* Taktik Local-First Data Layer
* Times Local-First Data Layer
*
* IndexedDB (Dexie.js) with sync support for time tracking.
* Clients, projects, time entries, tags, templates, and settings.
@ -13,7 +13,7 @@ import {
guestTags,
guestSettings,
} from './guest-seed';
import type { BillingRate, ProjectVisibility, EntrySourceRef } from '@taktik/shared';
import type { BillingRate, ProjectVisibility, EntrySourceRef } from '@times/shared';
// ─── Types ──────────────────────────────────────────────────
@ -97,8 +97,8 @@ export interface LocalSettings extends BaseRecord {
const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050';
export const taktikStore = createLocalStore({
appId: 'taktik',
export const timesStore = createLocalStore({
appId: 'times',
collections: [
{
name: 'clients',
@ -145,9 +145,9 @@ export const taktikStore = createLocalStore({
});
// Typed collection accessors
export const clientCollection = taktikStore.collection<LocalClient>('clients');
export const projectCollection = taktikStore.collection<LocalProject>('projects');
export const timeEntryCollection = taktikStore.collection<LocalTimeEntry>('timeEntries');
export const tagCollection = taktikStore.collection<LocalTag>('tags');
export const templateCollection = taktikStore.collection<LocalTemplate>('templates');
export const settingsCollection = taktikStore.collection<LocalSettings>('settings');
export const clientCollection = timesStore.collection<LocalClient>('clients');
export const projectCollection = timesStore.collection<LocalProject>('projects');
export const timeEntryCollection = timesStore.collection<LocalTimeEntry>('timeEntries');
export const tagCollection = timesStore.collection<LocalTag>('tags');
export const templateCollection = timesStore.collection<LocalTemplate>('templates');
export const settingsCollection = timesStore.collection<LocalSettings>('settings');

View file

@ -17,7 +17,7 @@ import {
getClientById,
getProjectsByClient,
} from './queries';
import type { TimeEntry, Project, Client } from '@taktik/shared';
import type { TimeEntry, Project, Client } from '@times/shared';
// ─── Test Factories ──────────────────────────────────────

View file

@ -1,5 +1,5 @@
/**
* Reactive Queries & Pure Helpers for Taktik
* Reactive Queries & Pure Helpers for Times
*
* Uses Dexie liveQuery to automatically re-render when IndexedDB changes
* (local writes, sync updates, other tabs).
@ -26,10 +26,10 @@ import type {
TimeEntry,
Tag,
EntryTemplate,
TaktikSettings,
TimesSettings,
FilterCriteria,
SortOption,
} from '@taktik/shared';
} from '@times/shared';
// ─── Type Converters ───────────────────────────────────────
@ -118,7 +118,7 @@ export function toTemplate(local: LocalTemplate): EntryTemplate {
};
}
export function toSettings(local: LocalSettings): TaktikSettings {
export function toSettings(local: LocalSettings): TimesSettings {
return {
id: local.id,
defaultBillingRate: local.defaultBillingRate ?? undefined,
@ -178,7 +178,7 @@ export function useSettings() {
const locals = await settingsCollection.getAll();
return locals.length > 0 ? toSettings(locals[0]) : null;
},
null as TaktikSettings | null
null as TimesSettings | null
);
}

View file

@ -5,11 +5,11 @@ import type {
TimeEntry,
Tag,
EntryTemplate,
TaktikSettings,
TimesSettings,
BillingRate,
FilterCriteria,
SortOption,
} from '@taktik/shared';
} from '@times/shared';
describe('Shared Types', () => {
it('BillingRate has correct shape', () => {

View file

@ -11,7 +11,7 @@ register('en', () => import('./locales/en.json'));
function getInitialLocale(): SupportedLocale {
if (browser) {
const stored = localStorage.getItem('taktik_locale');
const stored = localStorage.getItem('times_locale');
if (stored && supportedLocales.includes(stored as SupportedLocale)) {
return stored as SupportedLocale;
}
@ -31,7 +31,7 @@ init({
export function setLocale(newLocale: SupportedLocale) {
locale.set(newLocale);
if (browser) {
localStorage.setItem('taktik_locale', newLocale);
localStorage.setItem('times_locale', newLocale);
}
}

View file

@ -1,6 +1,6 @@
{
"app": {
"name": "Taktik",
"name": "Times",
"loading": "Laden...",
"tagline": "Dein Arbeitsrhythmus, messbar gemacht."
},

View file

@ -1,6 +1,6 @@
{
"app": {
"name": "Taktik",
"name": "Times",
"loading": "Loading...",
"tagline": "Your work rhythm, made measurable."
},

View file

@ -1,6 +1,6 @@
import { createThemeStore } from '@manacore/shared-theme';
export const theme = createThemeStore({
appId: 'taktik',
appId: 'times',
defaultVariant: 'ocean',
});

View file

@ -12,7 +12,7 @@ function getAuthUrl(): string {
}
export const userSettings = createUserSettingsStore({
appId: 'taktik',
appId: 'times',
authUrl: getAuthUrl,
getAccessToken: () => authStore.getAccessToken(),
});

View file

@ -1,9 +1,9 @@
import { browser } from '$app/environment';
import type { ViewMode, SortOption, FilterCriteria, SavedFilter } from '@taktik/shared';
import type { ViewMode, SortOption, FilterCriteria, SavedFilter } from '@times/shared';
const VIEW_KEY = 'taktik_view_mode';
const SORT_KEY = 'taktik_sort';
const FILTERS_KEY = 'taktik_saved_filters';
const VIEW_KEY = 'times_view_mode';
const SORT_KEY = 'times_sort';
const FILTERS_KEY = 'times_saved_filters';
function load<T>(key: string, fallback: T): T {
if (!browser) return fallback;

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import type { TimeEntry, Project, Client } from '@taktik/shared';
import type { TimeEntry, Project, Client } from '@times/shared';
// We test the CSV generation logic without triggering DOM download.
// This mirrors the core logic from export.ts.

View file

@ -2,7 +2,7 @@
* CSV Export utility for time entries
*/
import type { TimeEntry, Project, Client } from '@taktik/shared';
import type { TimeEntry, Project, Client } from '@times/shared';
export function exportEntriesToCSV(
entries: TimeEntry[],
@ -55,7 +55,7 @@ export function exportEntriesToCSV(
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `taktik-export-${new Date().toISOString().split('T')[0]}.csv`;
a.download = `times-export-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
}

View file

@ -4,7 +4,7 @@
* Applies rounding based on user settings (increment + method).
*/
import type { RoundingMethod } from '@taktik/shared';
import type { RoundingMethod } from '@times/shared';
/**
* Round a duration in seconds based on settings.

View file

@ -13,7 +13,7 @@
import { getPillAppItems } from '@manacore/shared-branding';
import { AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
import { taktikStore } from '$lib/data/local-store';
import { timesStore } from '$lib/data/local-store';
import {
useAllClients,
useAllProjects,
@ -46,17 +46,17 @@
setContext('settings', settings);
async function handleAuthReady() {
await taktikStore.initialize();
await timesStore.initialize();
if (authStore.isAuthenticated) {
taktikStore.startSync(() => authStore.getValidToken());
timesStore.startSync(() => authStore.getValidToken());
}
viewStore.initialize();
await timerStore.initialize();
initialized = true;
if (!authStore.isAuthenticated && shouldShowGuestWelcome('taktik')) {
if (!authStore.isAuthenticated && shouldShowGuestWelcome('times')) {
showGuestWelcome = true;
}
}
@ -105,7 +105,7 @@
/>
</svg>
</div>
<span class="text-lg font-bold text-[hsl(var(--foreground))]">Taktik</span>
<span class="text-lg font-bold text-[hsl(var(--foreground))]">Times</span>
</a>
<!-- Nav Items -->
@ -203,7 +203,7 @@
</button>
<GuestWelcomeModal
appId="taktik"
appId="times"
visible={showGuestWelcome}
onClose={() => (showGuestWelcome = false)}
onLogin={() => goto('/login')}

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import type { TimeEntry, Project, Client } from '@taktik/shared';
import type { TimeEntry, Project, Client } from '@times/shared';
import {
getEntriesByDate,
getTotalDuration,
@ -29,7 +29,7 @@
</script>
<svelte:head>
<title>Timer | Taktik</title>
<title>Timer | Times</title>
</svelte:head>
<div class="space-y-6">

View file

@ -3,8 +3,8 @@
import { _ } from 'svelte-i18n';
import { clientCollection } from '$lib/data/local-store';
import { getTotalDuration, formatDurationCompact } from '$lib/data/queries';
import type { Client, Project, TimeEntry } from '@taktik/shared';
import { PROJECT_COLORS } from '@taktik/shared/constants';
import type { Client, Project, TimeEntry } from '@times/shared';
import { PROJECT_COLORS } from '@times/shared/constants';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
const allClients = getContext<{ value: Client[] }>('clients');
@ -101,7 +101,7 @@
</script>
<svelte:head>
<title>{$_('nav.clients')} | Taktik</title>
<title>{$_('nav.clients')} | Times</title>
</svelte:head>
<div class="space-y-6">

View file

@ -10,7 +10,7 @@
formatDurationDecimal,
} from '$lib/data/queries';
import EntryList from '$lib/components/EntryList.svelte';
import type { Project, Client, TimeEntry } from '@taktik/shared';
import type { Project, Client, TimeEntry } from '@times/shared';
const allClients = getContext<{ value: Client[] }>('clients');
const allProjects = getContext<{ value: Project[] }>('projects');
@ -42,7 +42,7 @@
</script>
<svelte:head>
<title>{client?.name || 'Kunde'} | Taktik</title>
<title>{client?.name || 'Kunde'} | Times</title>
</svelte:head>
{#if !client}

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import type { TimeEntry } from '@taktik/shared';
import type { TimeEntry } from '@times/shared';
import {
getFilteredEntries,
getSortedEntries,
@ -56,7 +56,7 @@
</script>
<svelte:head>
<title>{$_('nav.entries')} | Taktik</title>
<title>{$_('nav.entries')} | Times</title>
</svelte:head>
<div class="space-y-6">

View file

@ -3,7 +3,7 @@
</script>
<svelte:head>
<title>Feedback | Taktik</title>
<title>Feedback | Times</title>
</svelte:head>
<div>

View file

@ -3,7 +3,7 @@
</script>
<svelte:head>
<title>Hilfe | Taktik</title>
<title>Hilfe | Times</title>
</svelte:head>
<div>

View file

@ -3,7 +3,7 @@
</script>
<svelte:head>
<title>Mana | Taktik</title>
<title>Mana | Times</title>
</svelte:head>
<div>

View file

@ -3,7 +3,7 @@
</script>
<svelte:head>
<title>Profil | Taktik</title>
<title>Profil | Times</title>
</svelte:head>
<div>

View file

@ -3,8 +3,8 @@
import { _ } from 'svelte-i18n';
import { projectCollection } from '$lib/data/local-store';
import { getTotalDuration, formatDurationCompact } from '$lib/data/queries';
import type { Project, Client, TimeEntry } from '@taktik/shared';
import { PROJECT_COLORS } from '@taktik/shared/constants';
import type { Project, Client, TimeEntry } from '@times/shared';
import { PROJECT_COLORS } from '@times/shared/constants';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
const allProjects = getContext<{ value: Project[] }>('projects');
@ -109,7 +109,7 @@
</script>
<svelte:head>
<title>{$_('nav.projects')} | Taktik</title>
<title>{$_('nav.projects')} | Times</title>
</svelte:head>
<div class="space-y-6">

View file

@ -11,8 +11,8 @@
formatDurationDecimal,
} from '$lib/data/queries';
import EntryList from '$lib/components/EntryList.svelte';
import type { Project, Client, TimeEntry } from '@taktik/shared';
import { PROJECT_COLORS } from '@taktik/shared/constants';
import type { Project, Client, TimeEntry } from '@times/shared';
import { PROJECT_COLORS } from '@times/shared/constants';
const allProjects = getContext<{ value: Project[] }>('projects');
const allClients = getContext<{ value: Client[] }>('clients');
@ -82,7 +82,7 @@
</script>
<svelte:head>
<title>{project?.name || 'Projekt'} | Taktik</title>
<title>{project?.name || 'Projekt'} | Times</title>
</svelte:head>
{#if !project}

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import type { TimeEntry, Project, Client } from '@taktik/shared';
import type { TimeEntry, Project, Client } from '@times/shared';
import { exportEntriesToCSV } from '$lib/utils/export';
import {
getTotalDuration,
@ -81,7 +81,7 @@
</script>
<svelte:head>
<title>{$_('nav.reports')} | Taktik</title>
<title>{$_('nav.reports')} | Times</title>
</svelte:head>
<div class="space-y-6">

View file

@ -2,10 +2,10 @@
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import { settingsCollection } from '$lib/data/local-store';
import type { TaktikSettings } from '@taktik/shared';
import { CURRENCIES, ROUNDING_INCREMENTS } from '@taktik/shared/constants';
import type { TimesSettings } from '@times/shared';
import { CURRENCIES, ROUNDING_INCREMENTS } from '@times/shared/constants';
const settings = getContext<{ value: TaktikSettings | null }>('settings');
const settings = getContext<{ value: TimesSettings | null }>('settings');
// Local edit state, synced from settings
let workingHoursPerDay = $state(8);
@ -49,7 +49,7 @@
</script>
<svelte:head>
<title>{$_('settings.title')} | Taktik</title>
<title>{$_('settings.title')} | Times</title>
</svelte:head>
<div class="space-y-6">

View file

@ -3,7 +3,7 @@
import { _ } from 'svelte-i18n';
import { templateCollection, timeEntryCollection } from '$lib/data/local-store';
import { timerStore } from '$lib/stores/timer.svelte';
import type { EntryTemplate, Project, Client } from '@taktik/shared';
import type { EntryTemplate, Project, Client } from '@times/shared';
const allTemplates = getContext<{ value: EntryTemplate[] }>('templates');
const allProjects = getContext<{ value: Project[] }>('projects');
@ -64,7 +64,7 @@
</script>
<svelte:head>
<title>{$_('nav.templates')} | Taktik</title>
<title>{$_('nav.templates')} | Times</title>
</svelte:head>
<div class="space-y-6">

View file

@ -3,7 +3,7 @@
</script>
<svelte:head>
<title>Themes | Taktik</title>
<title>Themes | Times</title>
</svelte:head>
<div>

View file

@ -96,7 +96,7 @@
</script>
<svelte:head>
<title>{showRegister ? $_('auth.register') : $_('auth.login')} | Taktik</title>
<title>{showRegister ? $_('auth.register') : $_('auth.login')} | Times</title>
</svelte:head>
<div class="flex min-h-screen items-center justify-center bg-[hsl(var(--background))] p-4">
@ -115,7 +115,7 @@
/>
</svg>
</div>
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">Taktik</h1>
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">Times</h1>
<p class="mt-1 text-sm text-[hsl(var(--muted-foreground))]">Zeiterfassung</p>
</div>
@ -256,7 +256,7 @@
<!-- App switcher -->
<div class="mt-6 flex flex-wrap justify-center gap-2">
{#each getPillAppItems() as app}
{#if app.id !== 'taktik'}
{#if app.id !== 'times'}
<a
href={app.url}
class="rounded-full border border-[hsl(var(--border))] px-3 py-1 text-xs text-[hsl(var(--muted-foreground))] transition-colors hover:bg-[hsl(var(--accent))] hover:text-[hsl(var(--accent-foreground))]"

View file

@ -5,6 +5,6 @@ export const GET: RequestHandler = async () => {
return json({
status: 'ok',
timestamp: new Date().toISOString(),
service: 'taktik-web',
service: 'times-web',
});
};

View file

@ -3,7 +3,7 @@
</script>
<svelte:head>
<title>Offline | Taktik</title>
<title>Offline | Times</title>
</svelte:head>
<div class="flex min-h-screen items-center justify-center bg-[hsl(var(--background))] p-4">

View file

@ -11,8 +11,8 @@ export default defineConfig({
SvelteKitPWA({
registerType: 'autoUpdate',
manifest: {
name: 'Taktik',
short_name: 'Taktik',
name: 'Times',
short_name: 'Times',
description: 'Zeiterfassung & Timetracking',
theme_color: '#f59e0b',
background_color: '#0f172a',

14
apps/times/package.json Normal file
View file

@ -0,0 +1,14 @@
{
"name": "times",
"version": "1.0.0",
"private": true,
"description": "Times - Zeiterfassung & Timetracking",
"scripts": {
"dev": "pnpm --filter @times/web dev",
"dev:web": "pnpm --filter @times/web dev"
},
"devDependencies": {
"typescript": "^5.9.3"
},
"packageManager": "pnpm@9.15.0"
}

View file

@ -1,5 +1,5 @@
{
"name": "@taktik/shared",
"name": "@times/shared",
"version": "1.0.0",
"private": true,
"type": "module",

View file

@ -110,7 +110,7 @@ export interface EntryTemplate {
export type RoundingMethod = 'none' | 'up' | 'down' | 'nearest';
export interface TaktikSettings {
export interface TimesSettings {
id: string;
defaultBillingRate?: BillingRate;
workingHoursPerDay: number;

View file

@ -287,7 +287,7 @@ services:
SMTP_PASS: ${SMTP_PASSWORD}
SYNAPSE_OIDC_CLIENT_SECRET: ${SYNAPSE_OIDC_CLIENT_SECRET:-}
MAX_DAILY_SIGNUPS: ${MAX_DAILY_SIGNUPS:-0}
CORS_ORIGINS: https://mana.how,https://calendar.mana.how,https://chat.mana.how,https://clock.mana.how,https://contacts.mana.how,https://context.mana.how,https://docs.mana.how,https://element.mana.how,https://inventar.mana.how,https://link.mana.how,https://manadeck.mana.how,https://matrix.mana.how,https://mukke.mana.how,https://nutriphi.mana.how,https://photos.mana.how,https://picture.mana.how,https://planta.mana.how,https://playground.mana.how,https://presi.mana.how,https://questions.mana.how,https://skilltree.mana.how,https://storage.mana.how,https://taktik.mana.how,https://todo.mana.how,https://traces.mana.how,https://zitare.mana.how
CORS_ORIGINS: https://mana.how,https://calendar.mana.how,https://chat.mana.how,https://clock.mana.how,https://contacts.mana.how,https://context.mana.how,https://docs.mana.how,https://element.mana.how,https://inventar.mana.how,https://link.mana.how,https://manadeck.mana.how,https://matrix.mana.how,https://mukke.mana.how,https://nutriphi.mana.how,https://photos.mana.how,https://picture.mana.how,https://planta.mana.how,https://playground.mana.how,https://presi.mana.how,https://questions.mana.how,https://skilltree.mana.how,https://storage.mana.how,https://times.mana.how,https://todo.mana.how,https://traces.mana.how,https://zitare.mana.how
ports:
- "3001:3001"
healthcheck:
@ -1355,12 +1355,12 @@ services:
retries: 3
start_period: 20s
taktik-web:
times-web:
build:
context: .
dockerfile: apps/taktik/apps/web/Dockerfile
image: taktik-web:local
container_name: mana-app-taktik-web
dockerfile: apps/times/apps/web/Dockerfile
image: times-web:local
container_name: mana-app-times-web
restart: always
mem_limit: 128m
depends_on:

View file

@ -0,0 +1,59 @@
{
"default_server_config": {
"m.homeserver": {
"base_url": "https://matrix.mana.how",
"server_name": "mana.how"
},
"m.identity_server": {
"base_url": ""
}
},
"brand": "ManaCore Chat",
"integrations_ui_url": "",
"integrations_rest_url": "",
"integrations_widgets_urls": [],
"disable_guests": true,
"disable_3pid_login": true,
"default_country_code": "DE",
"show_labs_settings": false,
"features": {
"feature_video_rooms": true,
"feature_group_calls": true,
"feature_thread": true
},
"room_directory": {
"servers": ["mana.how"]
},
"setting_defaults": {
"breadcrumbs": true,
"custom_themes": [],
"UIFeature.e2eeDefault": false,
"FTUE.userOnboardingButton": false,
"analyticsOptIn": false,
"pseudonymousAnalyticsOptIn": false
},
"ui_features": {
"UIFeature.RoomEncryptionSettings": false
},
"force_verification": false,
"enable_presence_by_hs_url": {},
"default_theme": "dark",
"permalink_prefix": "https://element.mana.how",
"terms_and_conditions_links": [],
"sso_redirect_options": {
"immediate": false,
"on_welcome_page": true
},
"posthog": {
"disabled": true
},
"sentry": {
"disabled": true
},
"analytics_owner": "",
"privacy_policy_url": "",
"show_analytics_setting": false,
"bug_report_endpoint_url": "",
"help_url": "https://mana.how/help",
"help_encryption_url": "https://element.io/help#encryption"
}

View file

@ -0,0 +1,433 @@
# Hardware-Analyse: 3-Node Home Server Cluster
**Stand: Maerz 2026** | Anforderung: K3s, YugabyteDB, ~60 Container (Go, Node/Bun, Python, SvelteKit)
## Referenz: Apple Mac Mini M4 (24 GB)
| Eigenschaft | Wert |
|---|---|
| CPU | Apple M4, 10-Core (4P + 6E), ARM64 |
| RAM | 24 GB unified (nicht aufruestbar) |
| SSD | 256 GB / 512 GB NVMe (nicht aufruestbar) |
| Netzwerk | 1x Gigabit Ethernet (10 GbE als Option: +30 EUR Thunderbolt-Adapter) |
| Leistungsaufnahme | **3-4 W idle / 25-35 W Last** |
| Preis (DE) | **929 EUR** (Apple Store), ab ~870 EUR (Geizhals, 24 GB / 512 GB) |
| Linux | Asahi Linux (Fedora-basiert), experimentell. macOS + Colima/Docker Desktop funktioniert |
| K3s | Nativ nur via Asahi Linux oder VM. Praxis: Colima + k3s in Lima-VM |
### Staerken
- Unschlagbare Energieeffizienz (3-4 W idle)
- Hervorragende Single- und Multi-Core-Performance
- Kompaktestes Gehaeuse am Markt (12.7 x 12.7 cm)
- Leise (passiv bei niedrigem Last)
### Schwaechen
- **Kein natives Linux** (Asahi experimentell, kein offizieller Support)
- **RAM und SSD nicht aufruestbar** (bei Kauf festgelegt)
- K3s laeuft nicht nativ auf macOS -- Virtualisierung noetig
- ARM64: ~20% der Helm Charts liefern nur x86-Images
- Kein 10 GbE onboard
---
## 1. ASUS NUC 14 Pro / Pro+
### ASUS NUC 14 Pro (Barebone)
| Eigenschaft | Wert |
|---|---|
| CPU | Intel Core Ultra 5 125H / Ultra 7 155H / Ultra 9 185H (Meteor Lake) |
| Kerne | 14C/18T (Ultra 7) bzw. 16C/22T (Ultra 9) |
| RAM | Bis 96 GB DDR5-5600 SO-DIMM (2 Slots, aufruestbar) |
| SSD | 1x M.2 2280 PCIe 4.0, 1x M.2 2242 |
| Netzwerk | 1x 2.5 GbE Intel i226-V |
| Leistungsaufnahme | **8-10 W idle / 45-88 W Last** |
| Preis (DE) | Barebone ab **235 EUR** (Geizhals), Komplettsystem ab **879 EUR** |
| Linux | Hervorragend (Ubuntu, Fedora nativ) |
| Verfuegbarkeit | Sofort lieferbar |
### ASUS NUC 14 Pro+ (Komplettsystem)
| Eigenschaft | Wert |
|---|---|
| CPU | Intel Core Ultra 7 155H / Ultra 9 185H |
| RAM | 16-32 GB DDR5 (aufruestbar bis 96 GB) |
| Preis (DE) | ab **879 EUR** (Komplettsystem mit 16 GB / 512 GB) |
| Besonderheit | Thunderbolt 4, WiFi 6E, vPro (manche Modelle) |
**Kalkulation fuer 32 GB Variante:**
- Barebone Ultra 7 155H: ~450 EUR
- 32 GB DDR5 SO-DIMM Kit: ~80 EUR
- 1 TB NVMe SSD: ~70 EUR
- **Gesamt: ~600 EUR pro Node**
### Bewertung vs. Mac Mini M4
| Kriterium | NUC 14 Pro | Mac Mini M4 |
|---|---|---|
| Preis (32 GB) | ~600 EUR | ~929 EUR |
| Idle-Verbrauch | 8-10 W | 3-4 W |
| Multi-Core | Vergleichbar (14C) | Leicht besser (effizienter) |
| RAM aufruestbar | Ja (bis 96 GB) | Nein |
| Linux nativ | Ja | Nein (Asahi experimentell) |
| 10 GbE | Nein (2.5 GbE) | Nein (1 GbE) |
**Fazit:** Bester Kompromiss aus Preis, Performance und Linux-Kompatibilitaet. Klare Empfehlung.
---
## 2. AMD Mini PCs
### Minisforum UM890 Pro
| Eigenschaft | Wert |
|---|---|
| CPU | AMD Ryzen 9 8945HS (8C/16T, Zen 4, bis 5.2 GHz) |
| RAM | Bis 64 GB DDR5-5600 SO-DIMM (2 Slots, aufruestbar) |
| SSD | 2x M.2 2280 PCIe 4.0 |
| Netzwerk | 1x 2.5 GbE |
| Leistungsaufnahme | **7-10 W idle / 80-95 W Last** |
| Preis (DE) | ab **463 EUR** (Geizhals), mit 32 GB/1 TB ca. **550-600 EUR** |
| Linux | Hervorragend (voller AMD-Support) |
| Verfuegbarkeit | Teilweise ausverkauft, Nachlieferung Mai 2026 |
### Beelink SER8
| Eigenschaft | Wert |
|---|---|
| CPU | AMD Ryzen 7 8845HS (8C/16T, Zen 4, bis 5.1 GHz) |
| RAM | 32 GB DDR5-5600 (aufruestbar) |
| SSD | 1 TB PCIe 4.0 |
| Netzwerk | 1x 2.5 GbE |
| Leistungsaufnahme | **8-10 W idle (Linux) / 85 W Last** |
| Preis (DE) | **399-450 EUR** (Amazon.de mit 32 GB/1 TB) |
| Linux | Hervorragend |
| Verfuegbarkeit | Sofort lieferbar |
### Beelink SER9 (Zen 5)
| Eigenschaft | Wert |
|---|---|
| CPU | AMD Ryzen AI 9 HX 370 (12C/24T, Zen 5) |
| RAM | Bis 64 GB DDR5 |
| Netzwerk | 1x 2.5 GbE |
| Leistungsaufnahme | ~10 W idle / ~95 W Last |
| Preis (DE) | ab ca. **650-750 EUR** (mit 32 GB) |
### Geekom A8 Max
| Eigenschaft | Wert |
|---|---|
| CPU | AMD Ryzen 9 8945HS (8C/16T) oder Ryzen 7 8745HS |
| RAM | Bis 64 GB DDR5 (aufruestbar) |
| SSD | Bis 2 TB |
| Netzwerk | **2x 2.5 GbE** (Dual-LAN!) |
| Leistungsaufnahme | ~8-10 W idle / ~85 W Last |
| Preis (DE) | ab **720 EUR** (mit Code), regulaer **849 EUR** |
| Besonderheit | USB4, Dual 2.5 GbE fuer LAG/Failover |
### Minisforum MS-A2 (Homelab-Spezialist)
| Eigenschaft | Wert |
|---|---|
| CPU | AMD Ryzen 9 9955HX (16C/32T, Zen 5) |
| RAM | Bis 96 GB DDR5 (2 Slots) |
| SSD | 3x M.2 (2280 + 22110 + U.2) |
| Netzwerk | **2x 10 GbE SFP+** + 2x 2.5 GbE |
| PCIe | 1x PCIe x16 Slot |
| Leistungsaufnahme | **25-30 W idle / 120+ W Last** |
| Preis (DE) | Barebone ab **839 EUR**, Komplett ab **975 EUR** |
| Linux | Hervorragend |
| Verfuegbarkeit | Teilweise ausverkauft |
**Fazit AMD:** Der **Beelink SER8** bietet das beste Preis-Leistungs-Verhaeltnis. Die **MS-A2** ist das Schweizer Taschenmesser fuer Homelabs mit 10 GbE, aber teuer und stromhungrig im Idle.
---
## 3. ARM-Alternativen
### Raspberry Pi 5 (8 GB)
| Eigenschaft | Wert |
|---|---|
| CPU | Broadcom BCM2712, 4x Cortex-A76 @ 2.4 GHz |
| RAM | 8 GB LPDDR4X (nicht aufruestbar) |
| SSD | Via M.2 HAT (NVMe) oder USB 3.0 SSD |
| Netzwerk | 1x Gigabit Ethernet |
| Leistungsaufnahme | **3-4 W idle / 10-12 W Last** |
| Preis (DE) | ca. **95 EUR** (Board + Gehaeuse + Kuehler + Netzteil) |
| Linux | Hervorragend (Raspberry Pi OS, Ubuntu) |
**Realistisch fuer diesen Workload? NEIN.**
- 8 GB RAM reicht nicht fuer YugabyteDB + K3s + Container
- Nur 4 Kerne -- ~60 Container werden sehr eng
- ~20% der Container-Images sind nur x86
- SD-Karten-IO ist ungeeignet (NVMe-HAT hilft, aber PCIe 2.0 x1)
- Kein ECC, instabil unter Dauerlast
- Muesste 5-6 Nodes statt 3 nutzen = mehr Komplexitaet
**Einsatz:** Gut als dedizierter K3s-Agent fuer leichte Workloads, nicht als Hauptknoten.
### Orange Pi 5 Plus / Rock 5B
| Eigenschaft | Orange Pi 5 Plus | Rock 5B |
|---|---|---|
| CPU | RK3588 8C (4x A76 + 4x A55) | RK3588 8C |
| RAM | 16 GB / 32 GB | 16 GB |
| Netzwerk | 2x 2.5 GbE | 1x 2.5 GbE |
| Preis | ~170 EUR (32 GB) | ~130 EUR (16 GB) |
| Leistungsaufnahme | 5-8 W idle | 5-8 W idle |
**Bewertung:** Mehr RAM als Pi 5, aber gleiche Grundprobleme: ARM-Image-Kompatibilitaet, begrenzte CPU-Leistung, kein ECC. Fuer 60 Container nicht empfohlen.
### Ampere Altra
| Eigenschaft | Wert |
|---|---|
| CPU | Ampere Altra (bis 128 ARM-Kerne) |
| RAM | Server-DDR5 ECC, bis 512 GB |
| Preis | ab **3.000+ EUR** fuer komplettes System |
**Bewertung:** Overkill und weit ueber Budget. Interessant fuer Datacenter, nicht fuer Home-Cluster.
---
## 4. Refurbished Enterprise Micro Server
### Lenovo ThinkCentre M75q Gen 5 Tiny
| Eigenschaft | Wert |
|---|---|
| CPU | AMD Ryzen 7 PRO 8700GE (8C/16T, 35W TDP) |
| RAM | Bis 64 GB DDR5 (2 Slots) |
| SSD | 1x M.2 2280 |
| Netzwerk | 1x 1 GbE (kein 2.5 GbE!) |
| Leistungsaufnahme | ~8-12 W idle / 35-50 W Last |
| Preis (DE, neu) | ab ca. **600-800 EUR** |
| Preis (refurbished) | ab ca. **350-500 EUR** |
| Linux | Hervorragend (Business-Hardware, gut getestet) |
### HP Elite Mini 800 G9
| Eigenschaft | Wert |
|---|---|
| CPU | Intel Core i5-12500T / i7-12700T (12./13. Gen) |
| RAM | Bis 64 GB DDR5 |
| SSD | 1x M.2 NVMe |
| Netzwerk | 1x 1 GbE |
| Leistungsaufnahme | ~10-15 W idle / 35-65 W Last |
| Preis (DE, refurbished) | ab ca. **300-450 EUR** (32 GB, refurbed.de) |
| Linux | Hervorragend |
### Dell OptiPlex 7010/7020 Micro
| Eigenschaft | Wert |
|---|---|
| CPU | Intel Core i5-13500T / i7-13700T |
| RAM | Bis 64 GB DDR5 (ab Gen 13) |
| Netzwerk | 1x 1 GbE |
| Preis (DE, refurbished) | ab ca. **280-400 EUR** |
| Linux | Hervorragend |
**Fazit Refurbished:** Extrem guenstig, aber nur 1 GbE Netzwerk und aeltere CPUs. Gut fuer Budget-Cluster, weniger fuer Zukunftssicherheit.
---
## 5. Ultra-Low-Power Mini PCs
### Intel N100 Mini PCs (z.B. Beelink S12 Pro, CWWK)
| Eigenschaft | Wert |
|---|---|
| CPU | Intel N100 (4C/4T, Alder Lake-N, bis 3.4 GHz) |
| RAM | 8-16 GB DDR5 (meistens geloetet, nicht aufruestbar) |
| SSD | 1x M.2 2280 |
| Netzwerk | 1x 2.5 GbE (CWWK: bis 6x 2.5 GbE) |
| Leistungsaufnahme | **6-8 W idle / 25 W Last** |
| Preis (DE) | ab **130-200 EUR** |
| Linux | Hervorragend |
### Intel N305 Mini PCs (z.B. CWWK F7)
| Eigenschaft | Wert |
|---|---|
| CPU | Intel i3-N305 (8C/8T, Alder Lake-N, bis 3.8 GHz) |
| RAM | Bis 32 GB DDR5 |
| SSD | 1-2x M.2 NVMe |
| Netzwerk | Bis 6x 2.5 GbE (CWWK/Topton Modelle) |
| Leistungsaufnahme | **10-14 W idle / 34-50 W Last** |
| Preis (DE) | ab **200-350 EUR** (mit 32 GB/1 TB) |
| Linux | Hervorragend |
| Besonderheit | Fanless-Modelle verfuegbar (24/7 Betrieb, lautlos) |
**Bewertung N100:** Nur 4 Kerne und max 16 GB RAM -- fuer YugabyteDB + 20 Container pro Node zu knapp.
**Bewertung N305:** 8 Kerne und 32 GB moeglich -- minimal ausreichend. Fanless und extrem stromsparend. Aber Single-Thread-Leistung weit unter Ryzen/Apple Silicon.
---
## Gesamtvergleich
### Performance und Effizienz
| System | Kerne | Idle (W) | Last (W) | Multi-Core (rel.) | Perf/Watt |
|---|---|---|---|---|---|
| **Mac Mini M4** | 10 (4P+6E) | 3-4 | 25-35 | 100% (Referenz) | Exzellent |
| ASUS NUC 14 Pro (Ultra 7) | 14 (6P+8E) | 8-10 | 45-88 | ~90-100% | Gut |
| Beelink SER8 (8845HS) | 8 (8P) | 8-10 | 85 | ~85% | Gut |
| Minisforum UM890 Pro | 8 (8P) | 7-10 | 80-95 | ~90% | Gut |
| Minisforum MS-A2 (9955HX) | 16 (16P) | 25-30 | 120+ | ~170% | Maessig |
| Lenovo M75q (8700GE) | 8 (8P) | 8-12 | 35-50 | ~75% | Gut |
| Intel N305 (CWWK) | 8 (8E) | 10-14 | 34-50 | ~35% | Gut (fuer idle) |
| Raspberry Pi 5 | 4 | 3-4 | 10-12 | ~15% | Gut (fuer idle) |
### Stromkosten ueber 3 Jahre (0,35 EUR/kWh, 3 Nodes)
| System | Idle (W, 3 Nodes) | Jahreskosten (idle) | 3-Jahres-Strom |
|---|---|---|---|
| **Mac Mini M4** | 9-12 W | 28-37 EUR | **84-110 EUR** |
| ASUS NUC 14 Pro | 24-30 W | 74-92 EUR | **221-276 EUR** |
| Beelink SER8 | 24-30 W | 74-92 EUR | **221-276 EUR** |
| Minisforum MS-A2 | 75-90 W | 230-276 EUR | **690-828 EUR** |
| Intel N305 | 30-42 W | 92-129 EUR | **276-387 EUR** |
| Raspberry Pi 5 | 9-12 W | 28-37 EUR | **84-110 EUR** |
*Annahme: Server laeuft 24/7, Grossteil der Zeit im Idle/Low-Load.*
### Total Cost of Ownership (3 Jahre: Hardware + Strom)
| System | Hardware (3x) | Strom (3 J.) | **TCO Gesamt** |
|---|---|---|---|
| **Mac Mini M4 (24 GB)** | 2.787 EUR | ~100 EUR | **~2.887 EUR** |
| ASUS NUC 14 Pro (32 GB) | 1.800 EUR | ~250 EUR | **~2.050 EUR** |
| Beelink SER8 (32 GB) | 1.200-1.350 EUR | ~250 EUR | **~1.500 EUR** |
| Minisforum UM890 Pro (32 GB) | 1.650 EUR | ~250 EUR | **~1.900 EUR** |
| Minisforum MS-A2 (32 GB) | 2.925 EUR | ~760 EUR | **~3.685 EUR** |
| Lenovo M75q refurb (32 GB) | 1.050-1.500 EUR | ~250 EUR | **~1.500 EUR** |
| Intel N305 CWWK (32 GB) | 750-1.050 EUR | ~330 EUR | **~1.200 EUR** |
---
## ARM vs. x86 Ueberlegungen fuer Docker/K8s
| Aspekt | ARM64 (Mac Mini, Pi) | x86_64 (Intel, AMD) |
|---|---|---|
| Container-Image-Verfuegbarkeit | ~80% (multi-arch nimmt zu) | 99%+ |
| Offizielle K3s-Unterstuetzung | Ja (ARM64 Builds) | Ja (primaer) |
| YugabyteDB | Offiziell nur x86! ARM experimentell | Voll unterstuetzt |
| Go-Services | Kein Problem (Cross-Compile) | Kein Problem |
| Node.js/Bun | Kein Problem | Kein Problem |
| Python | Kein Problem (manche C-Extensions langsamer) | Kein Problem |
| Helm Charts (Community) | ~80% kompatibel | ~99% kompatibel |
**Kritisch: YugabyteDB hat keinen offiziellen ARM64-Support.** Das allein disqualifiziert reine ARM-Setups fuer das geplante Setup, es sei denn man baut selbst oder nutzt einen Fork.
---
## Empfehlungen
### Empfehlung 1: Beelink SER8 (Bestes Preis-Leistungs-Verhaeltnis)
**3x Beelink SER8 (32 GB DDR5 / 1 TB SSD)**
| | |
|---|---|
| Preis pro Node | ~400-450 EUR |
| Gesamtpreis (3 Nodes) | ~1.200-1.350 EUR |
| TCO (3 Jahre) | ~1.500 EUR |
| Idle-Verbrauch (3 Nodes) | ~24-30 W |
- Sofort auf Amazon.de verfuegbar
- Linux-Support perfekt
- YugabyteDB laeuft nativ
- 8C/16T pro Node = 24C/48T gesamt -- mehr als genug fuer 60 Container
- 2.5 GbE Netzwerk
- Aufruestbar auf 64 GB RAM pro Node bei Bedarf
- **Spart ~1.400 EUR gegenueber 3x Mac Mini M4**
### Empfehlung 2: ASUS NUC 14 Pro (Bester Kompromiss)
**3x ASUS NUC 14 Pro Barebone (Ultra 7 155H) + 32 GB + 1 TB**
| | |
|---|---|
| Preis pro Node | ~600 EUR |
| Gesamtpreis (3 Nodes) | ~1.800 EUR |
| TCO (3 Jahre) | ~2.050 EUR |
- Bewaeehrte NUC-Qualitaet (Intel/ASUS)
- 14 Kerne pro Node = 42 Kerne gesamt
- Aufruestbar bis 96 GB RAM
- Thunderbolt 4 fuer 10 GbE Adapter
- Business-Hardware mit laengerer Verfuegbarkeit von Ersatzteilen
### Empfehlung 3: Minisforum MS-A2 (Maximum Homelab)
**Nur wenn 10 GbE zwingend noetig.**
| | |
|---|---|
| Preis pro Node | ~975 EUR |
| Gesamtpreis (3 Nodes) | ~2.925 EUR |
| TCO (3 Jahre) | ~3.685 EUR |
- 16 Kerne + 10 GbE SFP+ onboard
- PCIe x16 Slot fuer GPU/NIC-Erweiterung
- Aber: Hoher Idle-Verbrauch (25-30 W), teuer, oft ausverkauft
### Empfehlung 4: Hybrid (Budget + Zukunftssicherheit)
**2x Beelink SER8 + 1x ASUS NUC 14 Pro (als Control Plane)**
| | |
|---|---|
| Gesamtpreis | ~1.500 EUR |
| TCO (3 Jahre) | ~1.750 EUR |
---
## Nicht empfohlen
| Option | Grund |
|---|---|
| **Mac Mini M4** | Kein natives Linux, YugabyteDB nicht ARM-offiziell, RAM nicht aufruestbar, teuer |
| **Raspberry Pi 5** | Zu wenig RAM (8 GB), zu wenig CPU, ARM-Image-Probleme |
| **Intel N100** | Nur 4 Kerne, max 16 GB RAM -- zu schwach |
| **Ampere Altra** | Weit ueber Budget |
| **Minisforum MS-A2** | Nur wenn 10 GbE Pflicht (sonst zu teuer und zu stromhungrig) |
---
## Fazit
Fuer den geplanten Cluster mit K3s, YugabyteDB und ~60 Containern ist der **Beelink SER8** die wirtschaftlich beste Wahl: 3 Nodes fuer ~1.300 EUR, perfekter Linux-Support, ausreichend Leistung, und moderate Stromkosten. Die ~150 EUR mehr Stromkosten gegenueber Mac Minis werden durch ~1.400 EUR Hardware-Ersparnis mehr als kompensiert.
Falls die Anforderungen wachsen (mehr RAM, 10 GbE), ist ein spaeterer Umstieg auf ASUS NUC 14 Pro oder Minisforum MS-A2 Nodes moeglich -- K3s macht das Hinzufuegen und Entfernen von Nodes trivial.
**Wichtigster Grund gegen den Mac Mini M4:** YugabyteDB hat keinen offiziellen ARM64-Support. Das ist fuer eine produktive Datenbank ein No-Go.
---
## Quellen
- [Mac Mini M4 Effizienz - Jeff Geerling](https://www.jeffgeerling.com/blog/2024/m4-mac-minis-efficiency-incredible/)
- [Mac Mini M4 Review - NotebookCheck](https://www.notebookcheck.net/Apple-Mac-Mini-M4-review-Smaller-faster-and-louder.918832.0.html)
- [Mac Mini M4 Review - ServeTheHome](https://www.servethehome.com/the-apple-mac-mini-m4-sets-the-mini-computer-standard/3/)
- [ASUS NUC 14 Pro Review - ServeTheHome](https://www.servethehome.com/asus-nuc-14-pro-review-intel-core/4/)
- [ASUS NUC 14 Pro Geizhals](https://geizhals.de/asus-nuc-14-pro-kit-barebone-v158051.html)
- [Minisforum UM890 Pro Review - NotebookCheck](https://www.notebookcheck.net/Minisforum-EliteMini-UM890-Pro-review-A-powerful-mini-PC-with-AMD-Ryzen-9-and-whisper-quiet-cooling.982755.0.html)
- [Minisforum UM890 Pro Review - ServeTheHome](https://www.servethehome.com/minisforum-um890-pro-review-re-architected-amd-ryzen-8945hs-mini-pc/4/)
- [Minisforum MS-A2 Review - ServeTheHome](https://www.servethehome.com/minisforum-ms-a2-review-an-almost-perfect-amd-ryzen-intel-10gbe-homelab-system/4/)
- [Minisforum MS-A2 Geizhals](https://geizhals.de/minisforum-ms-a2-a3504299.html)
- [Beelink SER8 Review - ServeTheHome](https://www.servethehome.com/beelink-ser8-review-amd-ryzen-7-8845hs-powered-mini-pc/3/)
- [Beelink SER8 Review - Hardwareluxx](https://www.hardwareluxx.de/index.php/artikel/hardware/komplettsysteme/63918-mit-ryzen-7-8845hs-und-im-kompakten-geh%C3%A4use-beelink-ser8-im-test.html)
- [Beelink SER7 Amazon.de](https://www.amazon.de/Beelink-Ryzen-Threads-PCIe4-0-Display/dp/B0CH7VJZ94)
- [Geekom A8 Max - NotebookCheck](https://www.notebookcheck.net/Best-value-with-AMD-Ryzen-7-8845HS-The-Geekom-A8-Max-mini-PC-review.1005224.0.html)
- [Geekom DE Shop](https://www.geekom.de/geekom-a8-mini-pc/)
- [HP Elite Mini 800 G9 - refurbed.de](https://www.refurbed.de/p/hp-elite-mini-800-g9/)
- [Lenovo M75q Gen 2 Geizhals](https://geizhals.de/lenovo-thinkcentre-m75q-gen-2-v46509.html)
- [CWWK N305 Benchmarks](https://rovingclimber.com/2025/01/05/cwwk-i3-n305-benchmarks-power-consumption/)
- [N100 vs Pi - Jeff Geerling](https://www.jeffgeerling.com/blog/2025/intel-n100-better-value-raspberry-pi/)
- [N100 vs N305 Home Server](https://www.lowerhomeserver.vip/blog/hardware/intel-n100-vs-n305-home-server-2026)
- [K3s auf Raspberry Pi 5](https://www.picocluster.com/blogs/picocluster-software-engineering/installing-k3s-on-the-raspberry-pi-5-a-step-by-step-guide)

84
docs/FIX_COLIMA_MOUNTS.md Normal file
View file

@ -0,0 +1,84 @@
# Fix: Colima VirtioFS Mounts nach Stromausfall
Nach dem `colima delete` + recreate fehlt der Home-Directory Mount.
Dadurch werden alle Bind-Mounts zu Projekt-Dateien als leere Verzeichnisse gemountet
und Container wie Synapse, SearXNG, Alertmanager, VictoriaMetrics, Loki crashen.
## Schritte (direkt am Mac Mini ausfuehren)
### 1. Mac Mini neu starten (Power-Button)
SSH funktioniert nicht mehr — der Server muss physisch neugestartet werden.
### 2. Terminal oeffnen und Colima stoppen
```bash
PATH=/opt/homebrew/bin:$PATH colima stop
```
### 3. Home-Directory Mount in Colima Config einfuegen
```bash
sed -i '' '/^mounts:/a\
- location: /Users/mana\
writable: true
' ~/.colima/default/colima.yaml
```
### 4. Verifizieren dass beide Mounts drin sind
```bash
grep -A8 'mounts:' ~/.colima/default/colima.yaml
```
Erwartete Ausgabe:
```
mounts:
- location: /Users/mana
writable: true
- location: /Volumes/ManaData
writable: true
```
### 5. Colima starten
```bash
PATH=/opt/homebrew/bin:$PATH colima start
```
### 6. Testen ob File-Mounts funktionieren
```bash
docker run --rm -v ~/projects/manacore-monorepo/docker/alertmanager/alertmanager.yml:/test.yml:ro alpine head -3 /test.yml
```
Sollte YAML-Inhalt zeigen, NICHT "Is a directory".
### 7. Alle Container starten
```bash
cd ~/projects/manacore-monorepo
docker compose -f docker-compose.macmini.yml up -d --no-build
```
### 8. Pruefen ob die vorher crashenden Container laufen
```bash
docker ps --format 'table {{.Names}}\t{{.Status}}' | grep -E 'synapse|searxng|alertmanager|vmalert|victoria|loki'
```
Alle sollten "Up" und "healthy" zeigen.
### 9. Memory Baseline messen
```bash
./scripts/mac-mini/memory-baseline.sh
```
## Ursache
`colima delete` hat die VM komplett geloescht. Beim Neuerstellen mit
`colima start --mount /Volumes/ManaData:w` wurde nur das externe SSD
gemountet, nicht das Home-Directory `/Users/mana`. Ohne diesen Mount
sieht VirtioFS alle Host-Dateien als leere Verzeichnisse.

257
docs/MANA_BOX_HARDWARE.md Normal file
View file

@ -0,0 +1,257 @@
# Mana Box — On-Premise Appliance Hardware-Planung
**Stand:** 2026-03-30
**Status:** Recherche / Evaluierung
## Vision
Jeder Kunde (KMU, Verein, Schule, Agentur etc.) kauft eine **Mana Box** — einen kleinen, vorkonfigurierten Server mit dem gesamten Mana-Stack vorinstalliert. Alle Daten liegen on-premise beim Kunden. Optional kann der Kunde auch Websites, Intranets o.a. darauf hosten.
### Warum On-Premise?
- **Datensouveranitat**: Kundendaten verlassen nie das Gebaude
- **DSGVO-Compliance**: Keine Cloud-Abhaengigkeiten, keine Auftragsverarbeitung noetig
- **Unabhaengigkeit**: Funktioniert auch ohne Internet (Offline-First-Architektur)
- **Planbare Kosten**: Einmaliger Hardwarekauf + optionales Support-Abo
- **B2B-Differenzierung**: Kein Vendor-Lock-in, Kunde besitzt die Infrastruktur
## Architektur: Single-Node Appliance
Da jede Mana Box ein einzelner Server ist (kein Cluster), vereinfacht sich der Stack erheblich:
| Komponente | Cluster-Ansatz (verworfen) | Mana Box (Single Node) |
|---|---|---|
| Orchestrierung | K3s (Kubernetes) | **Docker Compose** |
| Datenbank | YugabyteDB (verteilt) | **PostgreSQL** (lokal) |
| Netzwerk | 10 GbE + Headscale Mesh | **1 GbE LAN** |
| High Availability | 3-Node Failover | **Backup-Strategie** |
| Management | kubectl | **Portainer oder CLI** |
### Stack pro Mana Box
```
┌─────────────────────────────────────────────┐
│ Mana Box │
├─────────────────────────────────────────────┤
│ Docker Compose │
│ ├── mana-auth (Hono/Bun) │
│ ├── mana-credits (Hono/Bun) │
│ ├── mana-user (Hono/Bun) │
│ ├── mana-sync (Go) │
│ ├── mana-search (Go) │
│ ├── mana-notify (Go) │
│ ├── mana-api-gateway (Go) │
│ ├── mana-media (Hono/Bun) │
│ ├── App-Backends (NestJS / Hono) │
│ ├── App-Webs (SvelteKit, static) │
│ ├── PostgreSQL │
│ ├── Redis │
│ ├── MinIO │
│ ├── Traefik (Reverse Proxy) │
│ └── Tailscale (Remote-Management) │
├─────────────────────────────────────────────┤
│ Debian/Ubuntu Minimal + unattended-upgrades│
└─────────────────────────────────────────────┘
```
## Hardware-Anforderungen
### Prioritaeten (anders als bei eigenem Cluster)
| Anforderung | Prioritaet | Begruendung |
|---|---|---|
| **Stueckpreis** | Kritisch | Bestimmt Verkaufspreis und Marge |
| **Zuverlaessigkeit** | Kritisch | Kein IT-Personal beim Kunden |
| **Lautstaerke** | Sehr hoch | Steht im Buero, nicht im Rack |
| **Stromverbrauch** | Hoch | Kunde zahlt Strom, 24/7 Betrieb |
| **Linux nativ** | Pflicht | Docker Compose direkt auf Host |
| **Kompakte Groesse** | Mittel | Schreibtisch oder Serverschrank |
| **RAM aufruetbar** | Niedrig | Wird vor Versand konfiguriert |
### RAM-Sizing nach Kundengroesse
| Tier | User | Typische Apps | RAM-Bedarf |
|---|---|---|---|
| **S** (Klein) | 5-15 | Auth + 3-4 Apps + DB + Redis | 6-8 GB |
| **M** (Mittel) | 15-30 | Auth + 8-10 Apps + DB + Redis + MinIO | 10-14 GB |
| **L** (Gross) | 30-50+ | Alle Apps + alle Services + MinIO | 16-20 GB |
**Sweet Spot: 16 GB RAM** deckt die grosse Mehrheit der KMU-Kunden ab.
32 GB nur fuer Power-User oder lokale LLM-Inferenz.
## Hardware-Kandidaten
### Vergleichstabelle
| Geraet | CPU | Cores | RAM | Preis (EK) | Luefter | Idle | Linux | Eignung |
|---|---|---|---|---|---|---|---|---|
| **Intel N100 Mini PC** (diverse) | Intel N100 | 4C/4T | 16 GB (aufr.) | 120-180 EUR | Luefterlos | ~6W | Nativ | Budget, nur 4 Cores |
| **Intel N305 Mini PC** (z.B. Beelink EQ14) | Intel N305 | 8C/8T | 16 GB (aufr.) | 200-280 EUR | Luefterlos | ~8W | Nativ | **Bester Kandidat fuer Box S** |
| **Beelink SER8** | Ryzen 7 8845HS | 8C/16T | 32 GB DDR5 (aufr.) | ~680 EUR | Aktiv (leise) | ~8W | Nativ | Overpowered fuer die meisten |
| **Beelink SER9 Pro (H255)** | Ryzen 7 H255 | 8C/16T | 32 GB LPDDR5X (fix) | ab 779 EUR | Aktiv (leise) | ~8W | Nativ | Gut, aber RAM verloetet |
| **Beelink SER9 Pro (HX 370)** | Ryzen AI 9 HX 370 | 12C/24T | 32 GB LPDDR5X (fix) | ab 1.029 EUR | Aktiv (leise) | ~10W | Nativ | Overkill, RAM verloetet |
| **Minisforum UM790 Pro** | Ryzen 9 7940HS | 8C/16T | bis 64 GB (aufr.) | ab 399 EUR | Aktiv (leise) | ~8W | Nativ | Gutes Preis/Leistung |
| **Minisforum UM890 Pro** | Ryzen 9 8945HS | 8C/16T | bis 64 GB (aufr.) | ab 463 EUR | Aktiv (leise) | ~8W | Nativ | Homelab-Favorit |
| **GEEKOM A8** | Ryzen 7 8745HS | 8C/16T | 16 GB DDR5 (aufr.) | ab 659 EUR | Aktiv (leise) | ~8W | Nativ | Dual 2.5 GbE, solide |
| **Lenovo M75q Gen 2** (refurb) | Ryzen 5 PRO 5650GE | 6C/12T | bis 64 GB (aufr.) | 150-250 EUR | Aktiv (leise) | ~10W | Nativ | **Guenstigster Kandidat** |
| **Lenovo M75q Gen 5** (neu) | Ryzen 7 PRO 8700GE | 8C/16T | bis 64 GB DDR5 (aufr.) | 500-600 EUR | Aktiv (leise) | ~10W | Nativ | Premium, Enterprise-Qualitaet |
| **ASUS NUC 14 Pro** (Barebone) | Core Ultra 7 155H | 14C/18T | bis 96 GB (aufr.) | ab 547 EUR | Aktiv (leise) | ~10W | Nativ | Viele Cores, flexibel |
| **HP Elite Mini 800 G9** (refurb) | Core i7-12700 | 12C/20T | 32 GB (aufr.) | ~350-500 EUR | Aktiv (leise) | ~12W | Nativ | Enterprise-refurb |
| **Raspberry Pi 5** | Cortex-A76 | 4C/4T | 8 GB (fix) | ~100 EUR | Passiv moegl. | ~3W | Nativ | Zu wenig RAM, ARM-Limits |
### Detailbewertung der Top-Kandidaten
#### Intel N305 Mini PCs (z.B. Beelink EQ14) — Box S
- **Preis:** 200-280 EUR
- **Vorteile:** Luefterlos, 8W idle, 8 Cores, extrem zuverlaessig, guenstig
- **Nachteile:** Nur E-Cores (kein Performance-Core), max ~16 GB typisch
- **Ideal fuer:** Kleine Kunden (5-15 User), Basic-Setup
- **Luefterlos = keine beweglichen Teile = hoechste Zuverlaessigkeit**
#### Minisforum UM890 Pro — Box M
- **Preis:** ab 463 EUR (Barebone), ~600-700 EUR konfiguriert
- **Vorteile:** Ryzen 9, aufruetbar bis 64 GB, sehr leise, bewahrt im Homelab
- **Nachteile:** Aktiver Luefter (wenn auch leise)
- **Ideal fuer:** Mittelgrosse Kunden (15-50 User)
#### Beelink SER8 — Box L
- **Preis:** ~680 EUR mit 32 GB/1 TB
- **Vorteile:** Ryzen 7 8845HS, DDR5 SO-DIMM aufruetbar bis 256 GB, 2.5 GbE
- **Nachteile:** Auslaufmodell (Nachfolger SER9 hat verloeteten RAM)
- **Ideal fuer:** Grosse Kunden, LLM-Workloads, Website-Hosting
- **Achtung:** Verfuegbarkeit pruefen — wird vom SER9 abgeloest
#### Lenovo M75q Gen 2 (refurbished) — Budget-Option
- **Preis:** 150-250 EUR (refurbished mit 12-24 Monate Garantie)
- **Vorteile:** Enterprise-Qualitaet, extrem guenstig, aufruetbar bis 64 GB
- **Nachteile:** Aeltere CPU (Ryzen 5 5650GE), DDR4, keine USB4
- **Bezugsquellen DE:** it-goods.de, notebookswieneu.de, thinkstore24.de
- **Ideal fuer:** Budget-bewusste Kunden, Prototyp-Phase
## Produkt-Tiers
### Mana Box S — 599-799 EUR (VK)
| Komponente | Spezifikation |
|---|---|
| Hardware | Intel N305 Mini PC, luefterlos |
| RAM | 16 GB DDR5 |
| Speicher | 512 GB NVMe SSD |
| Netzwerk | 1 GbE |
| Strom | ~8W idle |
| EK Hardware | ~250-300 EUR |
| Zielgruppe | 5-15 User |
| Apps | Auth + 3-5 Kern-Apps |
### Mana Box M — 999-1.299 EUR (VK)
| Komponente | Spezifikation |
|---|---|
| Hardware | Minisforum UM890 Pro oder Lenovo M75q Gen 5 |
| RAM | 32 GB DDR5 |
| Speicher | 1 TB NVMe SSD |
| Netzwerk | 2.5 GbE |
| Strom | ~10W idle |
| EK Hardware | ~500-650 EUR |
| Zielgruppe | 15-50 User |
| Apps | Auth + 8-15 Apps + MinIO |
### Mana Box L — 1.499-1.999 EUR (VK)
| Komponente | Spezifikation |
|---|---|
| Hardware | Beelink SER8 oder Minisforum MS-A1 |
| RAM | 64 GB DDR5 |
| Speicher | 2 TB NVMe SSD |
| Netzwerk | 2.5 GbE (oder 10 GbE bei MS-A1) |
| Strom | ~12W idle |
| EK Hardware | ~800-1.000 EUR |
| Zielgruppe | 50+ User, LLM lokal |
| Apps | Alle Apps + LLM-Inferenz |
## Betrieb & Management
### Provisioning
1. Debian 12 Minimal-Image mit vorinstalliertem Docker + Mana Stack
2. Image wird auf SSD geflasht (vor Versand oder per USB-Stick)
3. Kunde schliesst Box an Strom + LAN → Box startet automatisch
4. Ersteinrichtung via Web-UI (Admin-Account, Domain, App-Auswahl)
### Remote-Management
| Funktion | Technologie |
|---|---|
| VPN-Tunnel | Tailscale (oder WireGuard + eigener Koordinator) |
| Health-Monitoring | Phone-Home an Mana-Zentrale (Heartbeat, Metriken) |
| Remote-Shell | SSH via Tailscale (nur mit Kunden-Zustimmung) |
| Logs | Promtail → zentrale Loki-Instanz (optional, opt-in) |
### Updates
| Aspekt | Loesung |
|---|---|
| OS-Updates | unattended-upgrades (Debian) |
| App-Updates | Docker Image Pull + Compose Restart |
| Update-Kanal | Staging → Stable (Kunden koennen waehlen) |
| Rollback | Vorheriges Image bleibt lokal, 1-Click Rollback |
| Zeitfenster | Konfigurierbares Wartungsfenster (z.B. 03:00-05:00) |
### Backup
| Aspekt | Loesung |
|---|---|
| Datenbank | pg_dump, taeglich, verschluesselt |
| Dateien | MinIO/Medien-Backup |
| Ziel | Lokal (USB/NAS) und/oder verschluesselt in Mana Cloud |
| Retention | 7 Tage lokal, 30 Tage Cloud |
## Preiskalkulation (Beispiel Box M)
| Posten | Kosten |
|---|---|
| Hardware (EK) | ~600 EUR |
| Zusammenbau + Imaging + QA | ~50 EUR |
| Verpackung + Versand | ~30 EUR |
| **Gesamtkosten** | **~680 EUR** |
| **Verkaufspreis** | **999-1.299 EUR** |
| **Marge** | **~320-620 EUR (32-48%)** |
Zusaetzliche Einnahmen:
- Support-Abo: 29-99 EUR/Monat (Remote-Management, Updates, Monitoring)
- Erweiterungen: Zusaetzliche Apps, mehr Speicher, LLM-Modul
## Eigene Infrastruktur (weiterhin noetig)
Die Mana Boxen beim Kunden ersetzen nicht die eigene Infrastruktur. Weiterhin benoetigt:
| Zweck | Loesung |
|---|---|
| SaaS-Variante fuer Einzelnutzer | Mac Mini Cluster (2-3 Nodes) |
| Update-Server (Docker Registry) | Eigener Registry-Mirror |
| Health-Dashboard | Grafana + VictoriaMetrics |
| Tailscale Coordination | Headscale (self-hosted) |
| Landing Pages + Marketing | Cloudflare Pages |
| CI/CD (Image-Builds) | Forgejo Actions |
## Offene Fragen
- [ ] Welche Linux-Distribution? Debian 12 (stabil) vs. Ubuntu LTS (breiter Support)?
- [ ] Branding: Eigenes Gehaeuse/Aufkleber oder neutraler Mini PC?
- [ ] Garantie/Support: 1 Jahr vs. 2 Jahre? Austauschgeraet bei Defekt?
- [ ] App-Lizenzierung: Alle Apps inklusive oder modulares Lizenzmodell?
- [ ] LLM lokal: Ollama als optionales Modul? Welche Modelle passen in 16/32 GB?
- [ ] Backup-Cloud: Eigene S3-Instanz oder Hetzner Storage Box als Backup-Ziel?
- [ ] Pilotprojekt: Welcher Kunde/Anwendungsfall fuer den ersten Prototyp?
## Naechste Schritte
1. **Prototyp bauen**: N305 Mini PC oder M75q Gen 2 bestellen, Mana Stack als Docker Compose aufsetzen
2. **Benchmarken**: RAM/CPU-Verbrauch mit 5, 15, 30 simulierten Usern messen
3. **Provisioning-Image**: Reproduzierbares Debian-Image mit Packer oder cloud-init erstellen
4. **Remote-Management**: Tailscale/Headscale Setup testen
5. **Pricing validieren**: Gespraeche mit potenziellen Pilotkunden fuehren

View file

@ -136,9 +136,9 @@
"dev:tags-test": "./scripts/setup-databases.sh todo && ./scripts/setup-databases.sh calendar && ./scripts/setup-databases.sh contacts && ./scripts/setup-databases.sh auth && concurrently -n auth,todo-be,todo-web,cal-be,cal-web,con-be,con-web -c blue,green,cyan,yellow,magenta,red,white \"pnpm dev:auth\" \"pnpm dev:todo:backend\" \"pnpm dev:todo:web\" \"pnpm dev:calendar:backend\" \"pnpm dev:calendar:web\" \"pnpm dev:contacts:backend\" \"pnpm dev:contacts:web\"",
"inventar:dev": "turbo run dev --filter=inventar...",
"dev:inventar:web": "pnpm --filter @inventar/web dev",
"taktik:dev": "turbo run dev --filter=taktik...",
"dev:taktik:web": "pnpm --filter @taktik/web dev",
"dev:taktik:full": "concurrently -n auth,sync,web -c blue,magenta,cyan \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:taktik:web\"",
"times:dev": "turbo run dev --filter=times...",
"dev:times:web": "pnpm --filter @times/web dev",
"dev:times:full": "concurrently -n auth,sync,web -c blue,magenta,cyan \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:times:web\"",
"manavoxel:dev": "turbo run dev --filter=manavoxel...",
"dev:manavoxel:web": "pnpm --filter @manavoxel/web dev",
"dev:manavoxel:full": "concurrently -n auth,sync,web -c blue,magenta,cyan \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:manavoxel:web\"",

View file

@ -1,57 +0,0 @@
{
"name": "@manacore/nestjs-integration",
"version": "1.0.0",
"private": true,
"description": "NestJS integration package for Mana Core authentication and credits",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.js"
},
"./guards": {
"types": "./dist/guards/index.d.ts",
"import": "./dist/guards/index.js",
"require": "./dist/guards/index.js"
},
"./decorators": {
"types": "./dist/decorators/index.d.ts",
"import": "./dist/decorators/index.js",
"require": "./dist/decorators/index.js"
},
"./interceptors": {
"types": "./dist/interceptors/index.d.ts",
"import": "./dist/interceptors/index.js",
"require": "./dist/interceptors/index.js"
}
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"clean": "rm -rf dist",
"lint": "eslint .",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@manacore/credit-operations": "workspace:*",
"@nestjs/common": "^10.0.0 || ^11.0.0",
"@nestjs/config": "^3.0.0 || ^4.0.0",
"@nestjs/core": "^10.0.0 || ^11.0.0",
"reflect-metadata": "^0.1.13 || ^0.2.0",
"rxjs": "^7.0.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.0.0"
},
"peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
"@nestjs/config": "^3.0.0 || ^4.0.0",
"@nestjs/core": "^10.0.0 || ^11.0.0"
},
"files": [
"dist"
]
}

View file

@ -1,24 +0,0 @@
import { createParamDecorator } from '@nestjs/common';
import type { ExecutionContext } from '@nestjs/common';
export interface JwtPayload {
sub: string;
email: string;
role?: string;
app_id?: string;
iat?: number;
exp?: number;
}
export const CurrentUser = createParamDecorator(
(data: keyof JwtPayload | undefined, ctx: ExecutionContext): JwtPayload | string => {
const request = ctx.switchToHttp().getRequest();
const user = request.user as JwtPayload;
if (data) {
return user[data] as string;
}
return user;
}
);

View file

@ -1,3 +0,0 @@
export { CurrentUser, JwtPayload } from './current-user.decorator';
export { Public, IS_PUBLIC_KEY } from './public.decorator';
export { UseCredits, CreditOperationConfig, CREDIT_OPERATION_KEY } from './use-credits.decorator';

View file

@ -1,8 +0,0 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
/**
* Decorator to mark a route as public (no authentication required)
*/
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View file

@ -1,97 +0,0 @@
import { SetMetadata, applyDecorators, UseInterceptors } from '@nestjs/common';
import { CreditInterceptor } from '../interceptors/credit.interceptor';
import { type CreditOperationType } from '@manacore/credit-operations';
/**
* Metadata key for credit operation configuration.
*/
export const CREDIT_OPERATION_KEY = 'credit_operation';
/**
* Configuration for credit consumption.
*/
export interface CreditOperationConfig {
/**
* The operation type from the credit-operations package.
*/
operation: CreditOperationType;
/**
* Custom cost override. If not specified, uses the default from CREDIT_COSTS.
*/
customCost?: number;
/**
* Whether to consume credits before or after the handler execution.
* - 'before': Validate and reserve credits before execution (default)
* - 'after': Consume credits only after successful execution
*/
consumeMode?: 'before' | 'after';
/**
* Optional function to calculate cost dynamically based on request.
* Receives the request object and should return the credit cost.
*/
dynamicCost?: (request: any) => number;
/**
* Optional function to generate description for the transaction.
* Receives the request object and should return a description string.
*/
descriptionFn?: (request: any) => string;
/**
* Whether to skip the credit check in development mode.
* Default: false
*/
skipInDev?: boolean;
}
/**
* Decorator to require credits for an endpoint.
*
* @example Simple usage with operation type:
* ```typescript
* @Post('tasks')
* @UseCredits(CreditOperationType.TASK_CREATE)
* async createTask(@Body() dto: CreateTaskDto) {
* return this.taskService.create(dto);
* }
* ```
*
* @example With configuration object:
* ```typescript
* @Post('generate')
* @UseCredits({
* operation: CreditOperationType.AI_IMAGE_GENERATION,
* consumeMode: 'after',
* descriptionFn: (req) => `Generated image: ${req.body.prompt}`,
* })
* async generateImage(@Body() dto: GenerateDto) {
* return this.imageService.generate(dto);
* }
* ```
*
* @example With dynamic cost:
* ```typescript
* @Post('bulk-import')
* @UseCredits({
* operation: CreditOperationType.BULK_IMPORT,
* dynamicCost: (req) => Math.ceil(req.body.items.length / 10) * 0.2,
* })
* async bulkImport(@Body() dto: BulkImportDto) {
* return this.importService.import(dto);
* }
* ```
*/
export function UseCredits(
operationOrConfig: CreditOperationType | CreditOperationConfig
): MethodDecorator {
const config: CreditOperationConfig =
typeof operationOrConfig === 'string' ? { operation: operationOrConfig } : operationOrConfig;
return applyDecorators(
SetMetadata(CREDIT_OPERATION_KEY, config),
UseInterceptors(CreditInterceptor)
);
}

View file

@ -1,22 +0,0 @@
import { HttpException, HttpStatus } from '@nestjs/common';
export interface InsufficientCreditsDetails {
requiredCredits: number;
availableCredits: number;
creditType: 'user' | 'app';
operation: string;
}
export class InsufficientCreditsException extends HttpException {
constructor(details: InsufficientCreditsDetails) {
super(
{
statusCode: HttpStatus.PAYMENT_REQUIRED,
error: 'Insufficient Credits',
message: `Not enough credits for ${details.operation}. Required: ${details.requiredCredits}, Available: ${details.availableCredits}`,
details,
},
HttpStatus.PAYMENT_REQUIRED
);
}
}

View file

@ -1,176 +0,0 @@
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
Inject,
Optional,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { MANA_CORE_OPTIONS } from '../mana-core.module';
import { ManaCoreModuleOptions } from '../interfaces/mana-core-options.interface';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
interface TokenValidationResponse {
valid: boolean;
payload?: {
sub: string;
email: string;
role: string;
sessionId?: string;
sid?: string;
app_id?: string;
iat?: number;
exp?: number;
};
error?: string;
}
// Default development test user ID
const DEFAULT_DEV_USER_ID = '00000000-0000-0000-0000-000000000000';
/**
* JWT Authentication Guard for NestJS backends.
*
* Validates JWT tokens by calling the Mana Core Auth service.
* Supports development mode bypass via DEV_BYPASS_AUTH=true.
*/
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
@Optional()
@Inject(MANA_CORE_OPTIONS)
private readonly options?: ManaCoreModuleOptions,
@Optional()
private readonly reflector?: Reflector,
@Optional()
private readonly configService?: ConfigService
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
// Check if route is marked as public
if (this.reflector) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
}
const request = context.switchToHttp().getRequest();
// Development mode: bypass auth if DEV_BYPASS_AUTH is set
if (this.shouldBypassAuth()) {
request.user = this.getDevUser();
return true;
}
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException('No authorization token provided');
}
try {
const userData = await this.validateToken(token);
request.user = userData;
request.accessToken = token;
if (this.options?.debug) {
console.log('[AuthGuard] User authenticated:', userData.sub);
}
return true;
} catch (error) {
if (error instanceof UnauthorizedException) {
throw error;
}
if (this.options?.debug) {
console.error('[AuthGuard] Token validation failed:', error);
}
throw new UnauthorizedException('Invalid or expired token');
}
}
/**
* Check if auth should be bypassed (development mode)
*/
private shouldBypassAuth(): boolean {
const isDev =
this.configService?.get<string>('NODE_ENV') === 'development' ||
process.env.NODE_ENV === 'development';
const bypassAuth =
this.configService?.get<string>('DEV_BYPASS_AUTH') === 'true' ||
process.env.DEV_BYPASS_AUTH === 'true';
return isDev && bypassAuth;
}
/**
* Get development user data
*/
private getDevUser() {
const devUserId =
this.configService?.get<string>('DEV_USER_ID') ||
process.env.DEV_USER_ID ||
DEFAULT_DEV_USER_ID;
return {
sub: devUserId,
email: 'dev@example.com',
role: 'user',
app_id: this.options?.appId,
};
}
/**
* Validate token with Mana Core Auth service
*/
private async validateToken(token: string): Promise<any> {
const authUrl =
this.configService?.get<string>('MANA_CORE_AUTH_URL') ||
process.env.MANA_CORE_AUTH_URL ||
'http://localhost:3001';
const response = await fetch(`${authUrl}/api/v1/auth/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
if (this.options?.debug) {
console.error('[AuthGuard] Token validation failed:', response.status, errorText);
}
throw new UnauthorizedException('Invalid token');
}
const result = (await response.json()) as TokenValidationResponse;
if (!result.valid || !result.payload) {
throw new UnauthorizedException(result.error || 'Invalid token');
}
return {
sub: result.payload.sub,
email: result.payload.email,
role: result.payload.role,
app_id: result.payload.app_id || this.options?.appId,
sessionId: result.payload.sessionId || result.payload.sid,
iat: result.payload.iat,
exp: result.payload.exp,
};
}
private extractTokenFromHeader(request: any): string | undefined {
const authHeader = request.headers.authorization;
if (!authHeader) {
return undefined;
}
const [type, token] = authHeader.split(' ');
return type === 'Bearer' ? token : undefined;
}
}

View file

@ -1,2 +0,0 @@
export { AuthGuard } from './auth.guard';
export { OptionalAuthGuard } from './optional-auth.guard';

View file

@ -1,117 +0,0 @@
import { Injectable, CanActivate, ExecutionContext, Inject, Optional } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MANA_CORE_OPTIONS } from '../mana-core.module';
import { ManaCoreModuleOptions } from '../interfaces/mana-core-options.interface';
interface TokenValidationResponse {
valid: boolean;
payload?: {
sub: string;
email: string;
role: string;
sessionId?: string;
sid?: string;
app_id?: string;
iat?: number;
exp?: number;
};
error?: string;
}
/**
* Optional auth guard - allows unauthenticated requests but still validates and extracts user info if token is present
*/
@Injectable()
export class OptionalAuthGuard implements CanActivate {
constructor(
@Optional()
@Inject(MANA_CORE_OPTIONS)
private readonly options?: ManaCoreModuleOptions,
@Optional()
private readonly configService?: ConfigService
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) {
// No token - allow request but user will be undefined
request.user = null;
return true;
}
try {
const userData = await this.validateToken(token);
if (userData) {
request.user = userData;
request.accessToken = token;
if (this.options?.debug) {
console.log('[OptionalAuthGuard] User authenticated:', userData.sub);
}
} else {
request.user = null;
}
} catch (error) {
if (this.options?.debug) {
console.error('[OptionalAuthGuard] Token validation failed:', error);
}
// For optional auth, we allow the request to proceed even if token validation fails
request.user = null;
}
return true;
}
/**
* Validate token with Mana Core Auth service
*/
private async validateToken(token: string): Promise<any | null> {
const authUrl =
this.configService?.get<string>('MANA_CORE_AUTH_URL') ||
process.env.MANA_CORE_AUTH_URL ||
'http://localhost:3001';
try {
const response = await fetch(`${authUrl}/api/v1/auth/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
if (!response.ok) {
return null;
}
const result = (await response.json()) as TokenValidationResponse;
if (!result.valid || !result.payload) {
return null;
}
return {
sub: result.payload.sub,
email: result.payload.email,
role: result.payload.role,
app_id: result.payload.app_id || this.options?.appId,
sessionId: result.payload.sessionId || result.payload.sid,
iat: result.payload.iat,
exp: result.payload.exp,
};
} catch {
return null;
}
}
private extractTokenFromHeader(request: any): string | undefined {
const authHeader = request.headers.authorization;
if (!authHeader) {
return undefined;
}
const [type, token] = authHeader.split(' ');
return type === 'Bearer' ? token : undefined;
}
}

View file

@ -1,53 +0,0 @@
// Module
export { ManaCoreModule, MANA_CORE_OPTIONS } from './mana-core.module';
// Interfaces
export {
ManaCoreModuleOptions,
ManaCoreModuleAsyncOptions,
ManaCoreOptionsFactory,
} from './interfaces/mana-core-options.interface';
// Guards
export { AuthGuard } from './guards/auth.guard';
export { OptionalAuthGuard } from './guards/optional-auth.guard';
// Decorators
export { CurrentUser, JwtPayload } from './decorators/current-user.decorator';
export { Public, IS_PUBLIC_KEY } from './decorators/public.decorator';
export {
UseCredits,
CreditOperationConfig,
CREDIT_OPERATION_KEY,
} from './decorators/use-credits.decorator';
// Interceptors
export { CreditInterceptor } from './interceptors/credit.interceptor';
// Services
export {
CreditClientService,
CreditValidationResult,
CreditBalance,
} from './services/credit-client.service';
// Exceptions
export {
InsufficientCreditsException,
InsufficientCreditsDetails,
} from './exceptions/insufficient-credits.exception';
// Re-export credit operations for convenience
export {
CreditOperationType,
CREDIT_COSTS,
CreditCategory,
getCreditCost,
getOperationMetadata,
getOperationsForApp,
formatCreditCost,
getPricingTable,
isFreeOperation,
isMicroCreditOperation,
isAiOperation,
} from '@manacore/credit-operations';

View file

@ -1,195 +0,0 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
Logger,
Inject,
Optional,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable, tap, catchError, throwError } from 'rxjs';
import { CreditClientService } from '../services/credit-client.service';
import {
InsufficientCreditsException,
InsufficientCreditsDetails,
} from '../exceptions/insufficient-credits.exception';
import { CREDIT_OPERATION_KEY, CreditOperationConfig } from '../decorators/use-credits.decorator';
import { CREDIT_COSTS, getOperationMetadata } from '@manacore/credit-operations';
import { MANA_CORE_OPTIONS } from '../mana-core.module';
import { ManaCoreModuleOptions } from '../interfaces/mana-core-options.interface';
/**
* Interceptor that handles credit validation and consumption for decorated endpoints.
*
* This interceptor:
* 1. Checks if the user has sufficient credits before executing the handler
* 2. Consumes credits after successful execution (or before, depending on config)
* 3. Throws InsufficientCreditsException if the user doesn't have enough credits
*/
@Injectable()
export class CreditInterceptor implements NestInterceptor {
private readonly logger = new Logger(CreditInterceptor.name);
constructor(
private readonly reflector: Reflector,
private readonly creditClient: CreditClientService,
@Optional()
@Inject(MANA_CORE_OPTIONS)
private readonly options?: ManaCoreModuleOptions
) {}
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
const config = this.reflector.get<CreditOperationConfig>(
CREDIT_OPERATION_KEY,
context.getHandler()
);
// If no config, just proceed (shouldn't happen if decorator is used correctly)
if (!config) {
return next.handle();
}
const request = context.switchToHttp().getRequest();
const user = request.user;
// Check if user is authenticated
if (!user?.sub) {
this.logger.warn('No authenticated user found for credit operation');
return next.handle();
}
const userId = user.sub;
const operationName = config.operation;
// Calculate cost
const cost = this.calculateCost(config, request);
const consumeMode = config.consumeMode || 'after';
// Skip in development if configured
if (config.skipInDev && this.isDevelopment()) {
this.logger.debug(`Skipping credit check in development for ${operationName}`);
return next.handle();
}
// Validate credits before execution
const validation = await this.creditClient.validateCredits(userId, operationName, cost);
if (!validation.hasCredits) {
const details: InsufficientCreditsDetails = {
requiredCredits: cost,
availableCredits: validation.availableCredits,
creditType: 'user',
operation: operationName,
};
throw new InsufficientCreditsException(details);
}
// If consume mode is 'before', consume now
if (consumeMode === 'before') {
const description = this.generateDescription(config, request);
const consumed = await this.creditClient.consumeCredits(
userId,
operationName,
cost,
description,
this.buildMetadata(config, request)
);
if (!consumed) {
this.logger.error(`Failed to consume credits for ${operationName}`);
// Still allow the operation to proceed - fail open
}
return next.handle();
}
// If consume mode is 'after', consume on success
return next.handle().pipe(
tap(async () => {
const description = this.generateDescription(config, request);
const consumed = await this.creditClient.consumeCredits(
userId,
operationName,
cost,
description,
this.buildMetadata(config, request)
);
if (!consumed) {
this.logger.error(`Failed to consume credits after success for ${operationName}`);
} else if (this.options?.debug) {
this.logger.log(`Consumed ${cost} credits for ${operationName} (user: ${userId})`);
}
}),
catchError((error) => {
// Don't consume credits if the operation failed
this.logger.debug(`Operation ${operationName} failed, credits not consumed`);
return throwError(() => error);
})
);
}
/**
* Calculate the credit cost for the operation.
*/
private calculateCost(config: CreditOperationConfig, request: any): number {
// Dynamic cost takes priority
if (config.dynamicCost) {
return config.dynamicCost(request);
}
// Custom cost override
if (config.customCost !== undefined) {
return config.customCost;
}
// Default cost from CREDIT_COSTS
return CREDIT_COSTS[config.operation] || 0;
}
/**
* Generate a description for the credit transaction.
*/
private generateDescription(config: CreditOperationConfig, request: any): string {
// Custom description function
if (config.descriptionFn) {
return config.descriptionFn(request);
}
// Default description from operation metadata
const metadata = getOperationMetadata(config.operation);
return metadata?.name || config.operation;
}
/**
* Build metadata for the credit transaction.
*/
private buildMetadata(config: CreditOperationConfig, request: any): Record<string, any> {
const metadata: Record<string, any> = {
operation: config.operation,
path: request.path,
method: request.method,
};
// Add app info from operation metadata
const opMeta = getOperationMetadata(config.operation);
if (opMeta) {
metadata.app = opMeta.app;
metadata.category = opMeta.category;
}
return metadata;
}
/**
* Check if running in development mode.
*/
private isDevelopment(): boolean {
return (
this.options?.debug ||
process.env.NODE_ENV === 'development' ||
process.env.NODE_ENV === 'dev'
);
}
}

View file

@ -1 +0,0 @@
export { CreditInterceptor } from './credit.interceptor';

View file

@ -1,24 +0,0 @@
import { type ModuleMetadata } from '@nestjs/common';
import type { Type } from '@nestjs/common';
export interface ManaCoreModuleOptions {
/**
* @deprecated No longer used - auth URL is read from MANA_CORE_AUTH_URL env variable
*/
manaServiceUrl?: string;
appId: string;
serviceKey?: string;
signupRedirectUrl?: string;
debug?: boolean;
}
export interface ManaCoreOptionsFactory {
createManaCoreOptions(): Promise<ManaCoreModuleOptions> | ManaCoreModuleOptions;
}
export interface ManaCoreModuleAsyncOptions extends Pick<ModuleMetadata, 'imports'> {
useExisting?: Type<ManaCoreOptionsFactory>;
useClass?: Type<ManaCoreOptionsFactory>;
useFactory?: (...args: any[]) => Promise<ManaCoreModuleOptions> | ManaCoreModuleOptions;
inject?: any[];
}

View file

@ -1,83 +0,0 @@
import { DynamicModule, Module, Global, Provider } from '@nestjs/common';
import {
ManaCoreModuleOptions,
ManaCoreModuleAsyncOptions,
ManaCoreOptionsFactory,
} from './interfaces/mana-core-options.interface';
import { AuthGuard } from './guards/auth.guard';
import { CreditClientService } from './services/credit-client.service';
export const MANA_CORE_OPTIONS = 'MANA_CORE_OPTIONS';
@Global()
@Module({})
export class ManaCoreModule {
static forRoot(options: ManaCoreModuleOptions): DynamicModule {
return {
module: ManaCoreModule,
providers: [
{
provide: MANA_CORE_OPTIONS,
useValue: options,
},
AuthGuard,
CreditClientService,
],
exports: [MANA_CORE_OPTIONS, AuthGuard, CreditClientService],
};
}
static forRootAsync(options: ManaCoreModuleAsyncOptions): DynamicModule {
const asyncProviders = this.createAsyncProviders(options);
return {
module: ManaCoreModule,
imports: options.imports || [],
providers: [...asyncProviders, AuthGuard, CreditClientService],
exports: [MANA_CORE_OPTIONS, AuthGuard, CreditClientService],
};
}
private static createAsyncProviders(options: ManaCoreModuleAsyncOptions): Provider[] {
if (options.useFactory) {
return [
{
provide: MANA_CORE_OPTIONS,
useFactory: options.useFactory,
inject: options.inject || [],
},
];
}
const useClass = options.useClass;
const useExisting = options.useExisting;
if (useClass) {
return [
{
provide: MANA_CORE_OPTIONS,
useFactory: async (optionsFactory: ManaCoreOptionsFactory) =>
await optionsFactory.createManaCoreOptions(),
inject: [useClass],
},
{
provide: useClass,
useClass,
},
];
}
if (useExisting) {
return [
{
provide: MANA_CORE_OPTIONS,
useFactory: async (optionsFactory: ManaCoreOptionsFactory) =>
await optionsFactory.createManaCoreOptions(),
inject: [useExisting],
},
];
}
return [];
}
}

View file

@ -1,243 +0,0 @@
import { Injectable, Inject, Optional, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MANA_CORE_OPTIONS } from '../mana-core.module';
import { ManaCoreModuleOptions } from '../interfaces/mana-core-options.interface';
export interface CreditValidationResult {
hasCredits: boolean;
availableCredits: number;
requiredCredits?: number;
}
export interface CreditBalance {
balance: number;
totalEarned: number;
totalSpent: number;
}
@Injectable()
export class CreditClientService {
private readonly logger = new Logger(CreditClientService.name);
constructor(
@Optional()
@Inject(MANA_CORE_OPTIONS)
private readonly options?: ManaCoreModuleOptions,
@Optional()
private readonly configService?: ConfigService
) {}
private getAuthUrl(): string {
return (
this.configService?.get<string>('MANA_CORE_AUTH_URL') ||
process.env.MANA_CORE_AUTH_URL ||
'http://localhost:3001'
);
}
/**
* Get the credits service URL. Uses MANA_CREDITS_URL if available,
* falls back to MANA_CORE_AUTH_URL for backward compatibility.
*/
private getCreditsUrl(): string {
return (
this.configService?.get<string>('MANA_CREDITS_URL') ||
process.env.MANA_CREDITS_URL ||
this.getAuthUrl()
);
}
private getServiceKey(): string {
return (
this.options?.serviceKey ||
this.configService?.get<string>('MANA_CORE_SERVICE_KEY') ||
process.env.MANA_CORE_SERVICE_KEY ||
''
);
}
private getAppId(): string {
return (
this.options?.appId || this.configService?.get<string>('APP_ID') || process.env.APP_ID || ''
);
}
async validateCredits(
userId: string,
operation: string,
requiredAmount: number
): Promise<CreditValidationResult> {
try {
const balance = await this.getBalance(userId);
return {
hasCredits: balance.balance >= requiredAmount,
availableCredits: balance.balance,
requiredCredits: requiredAmount,
};
} catch (error) {
this.logger.error(`Failed to validate credits for user ${userId}:`, error);
// In case of error, we allow the operation to proceed
// The actual credit deduction will fail if there are no credits
return {
hasCredits: true,
availableCredits: 0,
requiredCredits: requiredAmount,
};
}
}
async getBalance(userId: string): Promise<CreditBalance> {
const creditsUrl = this.getCreditsUrl();
const serviceKey = this.getServiceKey();
if (!serviceKey) {
this.logger.warn('Service key not configured, returning default balance');
return {
balance: 1000,
totalEarned: 0,
totalSpent: 0,
};
}
try {
const response = await fetch(`${creditsUrl}/api/v1/internal/credits/balance/${userId}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-Service-Key': serviceKey,
'X-App-Id': this.getAppId(),
},
});
if (!response.ok) {
this.logger.warn(`Credit balance request failed: ${response.status}`);
return this.getDefaultBalance();
}
const {
balance = 0,
totalEarned = 0,
totalSpent = 0,
} = (await response.json()) as CreditBalance;
return {
balance,
totalEarned,
totalSpent,
};
} catch (error) {
this.logger.error(`Failed to get balance for user ${userId}:`, error);
return this.getDefaultBalance();
}
}
async consumeCredits(
userId: string,
operation: string,
amount: number,
description: string,
metadata?: Record<string, any>,
creditSource?: { type: 'personal' } | { type: 'guild'; guildId: string }
): Promise<boolean> {
const creditsUrl = this.getCreditsUrl();
const serviceKey = this.getServiceKey();
if (!serviceKey) {
this.logger.warn('Service key not configured, skipping credit consumption');
return true;
}
try {
const response = await fetch(`${creditsUrl}/api/v1/internal/credits/use`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Service-Key': serviceKey,
'X-App-Id': this.getAppId(),
},
body: JSON.stringify({
userId,
amount,
appId: this.getAppId(),
description,
metadata: {
operation,
...metadata,
},
...(creditSource && { creditSource }),
}),
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
this.logger.error(`Failed to consume credits: ${response.status} ${errorText}`);
return false;
}
if (this.options?.debug) {
this.logger.log(`Consumed ${amount} credits for user ${userId}: ${description}`);
}
return true;
} catch (error) {
this.logger.error(`Failed to consume credits for user ${userId}:`, error);
return false;
}
}
async refundCredits(
userId: string,
amount: number,
description: string,
metadata?: Record<string, any>
): Promise<boolean> {
const creditsUrl = this.getCreditsUrl();
const serviceKey = this.getServiceKey();
if (!serviceKey) {
this.logger.warn('Service key not configured, skipping credit refund');
return true;
}
try {
const response = await fetch(`${creditsUrl}/api/v1/internal/credits/refund`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Service-Key': serviceKey,
'X-App-Id': this.getAppId(),
},
body: JSON.stringify({
userId,
amount,
appId: this.getAppId(),
description,
metadata,
}),
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
this.logger.error(`Failed to refund credits: ${response.status} ${errorText}`);
return false;
}
if (this.options?.debug) {
this.logger.log(`Refunded ${amount} credits for user ${userId}: ${description}`);
}
return true;
} catch (error) {
this.logger.error(`Failed to refund credits for user ${userId}:`, error);
return false;
}
}
private getDefaultBalance(): CreditBalance {
return {
balance: 1000,
totalEarned: 0,
totalSpent: 0,
};
}
}

View file

@ -1,21 +0,0 @@
{
"compilerOptions": {
"target": "ES2021",
"module": "commonjs",
"lib": ["ES2021"],
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -76,7 +76,7 @@ const playgroundSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" f
const citycornersSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#ccGrad)"/><path d="M512 200C408.3 200 324 284.3 324 388C324 536 512 800 512 800C512 800 700 536 700 388C700 284.3 615.7 200 512 200ZM512 468C467.8 468 432 432.2 432 388C432 343.8 467.8 308 512 308C556.2 308 592 343.8 592 388C592 432.2 556.2 468 512 468Z" fill="white"/><circle cx="512" cy="388" r="60" fill="#2563eb" fill-opacity="0.4"/><defs><linearGradient id="ccGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#2563eb"/><stop offset="1" stop-color="#1d4ed8"/></linearGradient></defs></svg>`;
// Taktik icon (clock with play button, amber gradient)
const taktikSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#taktikGrad)"/><circle cx="512" cy="480" r="220" stroke="white" stroke-width="40"/><path d="M512 340V480L600 560" stroke="white" stroke-width="36" stroke-linecap="round" stroke-linejoin="round"/><circle cx="512" cy="480" r="20" fill="white"/><path d="M480 700L560 740L480 780Z" fill="white" fill-opacity="0.6"/><defs><linearGradient id="taktikGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#f59e0b"/><stop offset="1" stop-color="#d97706"/></linearGradient></defs></svg>`;
const timesSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#timesGrad)"/><circle cx="512" cy="480" r="220" stroke="white" stroke-width="40"/><path d="M512 340V480L600 560" stroke="white" stroke-width="36" stroke-linecap="round" stroke-linejoin="round"/><circle cx="512" cy="480" r="20" fill="white"/><path d="M480 700L560 740L480 780Z" fill="white" fill-opacity="0.6"/><defs><linearGradient id="timesGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#f59e0b"/><stop offset="1" stop-color="#d97706"/></linearGradient></defs></svg>`;
// Calc icon (calculator with pink gradient)
const calcSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#calcGrad)"/><rect x="320" y="260" width="384" height="504" rx="32" fill="white"/><rect x="360" y="300" width="304" height="100" rx="16" fill="#ec4899" fill-opacity="0.2"/><rect x="380" y="330" width="200" height="16" rx="4" fill="#ec4899" fill-opacity="0.5"/><rect x="380" y="358" width="120" height="24" rx="4" fill="#ec4899" fill-opacity="0.7"/><rect x="360" y="440" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="440" y="440" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="520" y="440" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="600" y="440" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.3"/><rect x="360" y="508" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="440" y="508" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="520" y="508" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="600" y="508" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.3"/><rect x="360" y="576" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="440" y="576" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="520" y="576" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="600" y="576" width="64" height="120" rx="12" fill="#ec4899"/><rect x="360" y="644" width="144" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="520" y="644" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><defs><linearGradient id="calcGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#ec4899"/><stop offset="1" stop-color="#db2777"/></linearGradient></defs></svg>`;
@ -112,7 +112,7 @@ export const APP_ICONS = {
playground: svgToDataUrl(playgroundSvg),
context: svgToDataUrl(contextSvg),
citycorners: svgToDataUrl(citycornersSvg),
taktik: svgToDataUrl(taktikSvg),
times: svgToDataUrl(timesSvg),
calc: svgToDataUrl(calcSvg),
uload: svgToDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="ug" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#6366f1"/><stop offset="100%" style="stop-color:#818cf8"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#ug)"/><path d="M35 45a10 10 0 0 1 10-10h0a10 10 0 0 1 0 20h0M65 55a10 10 0 0 1-10 10h0a10 10 0 0 1 0-20h0M42 58l16-16" stroke="white" stroke-width="5" stroke-linecap="round" fill="none"/></svg>`

View file

@ -341,7 +341,7 @@ export const MANA_APPS: ManaApp[] = [
status: 'development',
},
{
id: 'taktik',
id: 'times',
name: 'Taktik',
description: {
de: 'Zeiterfassung & Timetracking',
@ -351,7 +351,7 @@ export const MANA_APPS: ManaApp[] = [
de: 'Professionelle Zeiterfassung mit Timer, Projekten, Kunden, Reports und Gilden-Integration.',
en: 'Professional time tracking with timer, projects, clients, reports, and guild integration.',
},
icon: APP_ICONS.taktik,
icon: APP_ICONS.times,
color: '#f59e0b',
comingSoon: false,
status: 'development',
@ -527,7 +527,7 @@ export const APP_URLS: Record<AppIconId, { dev: string; prod: string }> = {
playground: { dev: 'http://localhost:5190', prod: 'https://playground.mana.how' },
context: { dev: 'http://localhost:5192', prod: 'https://context.mana.how' },
citycorners: { dev: 'http://localhost:5196', prod: 'https://citycorners.mana.how' },
taktik: { dev: 'http://localhost:5197', prod: 'https://taktik.mana.how' },
times: { dev: 'http://localhost:5197', prod: 'https://times.mana.how' },
uload: { dev: 'http://localhost:5173', prod: 'https://ulo.ad' },
reader: { dev: 'exp://localhost:8081', prod: 'https://reader.mana.how' },
news: { dev: 'http://localhost:5174', prod: 'https://news.mana.how' },

View file

@ -264,7 +264,7 @@ export function createBetterAuth(databaseUrl: string) {
'https://questions.mana.how',
'https://skilltree.mana.how',
'https://storage.mana.how',
'https://taktik.mana.how',
'https://times.mana.how',
'https://todo.mana.how',
'https://traces.mana.how',
'https://zitare.mana.how',

View file

@ -192,4 +192,4 @@ services/mana-sync/
## Connected Apps (19)
Todo, Calendar, Clock, Contacts, Chat, Questions, Mukke, Context, Photos, ManaDeck, Picture, Presi, Storage, Zitare, SkillTree, CityCorners, NutriPhi, Planta, Inventar, uLoad, Taktik, Calc
Todo, Calendar, Clock, Contacts, Chat, Questions, Mukke, Context, Photos, ManaDeck, Picture, Presi, Storage, Zitare, SkillTree, CityCorners, NutriPhi, Planta, Inventar, uLoad, Times, Calc