mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:01:09 +02:00
feat(skilltree): add SkillTree MVP - gamified skill tracking app
- SvelteKit web app with Svelte 5 runes - IndexedDB storage for offline-first experience - 6 skill branches: Intellect, Body, Creativity, Social, Practical, Mindset - XP system with 6 levels (Unbekannt -> Meister) - Activity logging with timestamps - Stats overview (total XP, skills, streak) - Branch filtering and recent activities feed https://claude.ai/code/session_015XCsTDS9aLZ64Zin4HU6ex
This commit is contained in:
parent
bea066c7f8
commit
12ad8e83d5
19 changed files with 1655 additions and 0 deletions
104
apps/skilltree/CLAUDE.md
Normal file
104
apps/skilltree/CLAUDE.md
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
# SkillTree
|
||||
|
||||
Gamified personal skill tracking app - like an RPG skill tree for real life.
|
||||
|
||||
## Overview
|
||||
|
||||
Track your skills, earn XP through activities, and level up your abilities across different life domains.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Web**: SvelteKit + Svelte 5 + Tailwind CSS
|
||||
- **Storage**: IndexedDB (offline-first, no backend needed)
|
||||
- **State**: Svelte 5 runes (`$state`, `$derived`)
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Start development server (port 5195)
|
||||
pnpm dev:web
|
||||
|
||||
# Or from monorepo root
|
||||
pnpm --filter @skilltree/web dev
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
apps/skilltree/
|
||||
├── apps/
|
||||
│ └── web/ # SvelteKit web app
|
||||
│ ├── src/
|
||||
│ │ ├── lib/
|
||||
│ │ │ ├── components/ # UI components
|
||||
│ │ │ ├── services/ # IndexedDB storage
|
||||
│ │ │ ├── stores/ # Svelte 5 reactive stores
|
||||
│ │ │ └── types/ # TypeScript types
|
||||
│ │ └── routes/ # SvelteKit routes
|
||||
│ └── static/ # Static assets
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### MVP (Current)
|
||||
|
||||
- [x] Skill creation with name, description, and branch
|
||||
- [x] Six skill branches: Intellect, Body, Creativity, Social, Practical, Mindset
|
||||
- [x] XP system with 6 levels (0-5)
|
||||
- [x] Activity logging with XP rewards
|
||||
- [x] Stats overview (total XP, skills, highest level, streak)
|
||||
- [x] Offline-first with IndexedDB
|
||||
- [x] Branch filtering
|
||||
- [x] Recent activities feed
|
||||
|
||||
### Planned
|
||||
|
||||
- [ ] Skill editing
|
||||
- [ ] Skill tree visualization (graph view)
|
||||
- [ ] Skill dependencies/prerequisites
|
||||
- [ ] Achievements/badges
|
||||
- [ ] Data export/import
|
||||
- [ ] Cloud sync (optional)
|
||||
|
||||
## Data Model
|
||||
|
||||
### Skill
|
||||
```typescript
|
||||
interface Skill {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
branch: SkillBranch;
|
||||
parentId: string | null;
|
||||
icon: string;
|
||||
color: string | null;
|
||||
currentXp: number;
|
||||
totalXp: number;
|
||||
level: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Levels
|
||||
|
||||
| Level | Name | XP Required |
|
||||
|-------|---------------|-------------|
|
||||
| 0 | Unbekannt | 0 |
|
||||
| 1 | Anfänger | 100 |
|
||||
| 2 | Fortgeschritten | 500 |
|
||||
| 3 | Kompetent | 1,500 |
|
||||
| 4 | Experte | 4,000 |
|
||||
| 5 | Meister | 10,000 |
|
||||
|
||||
## Branches
|
||||
|
||||
| Branch | Icon | Color | Description |
|
||||
|------------|-----------|---------|--------------------------------|
|
||||
| Intellect | brain | blue | Knowledge, languages, science |
|
||||
| Body | dumbbell | red | Fitness, sports, health |
|
||||
| Creativity | palette | pink | Art, music, writing |
|
||||
| Social | users | purple | Communication, leadership |
|
||||
| Practical | wrench | orange | Crafts, cooking, tech |
|
||||
| Mindset | heart | emerald | Meditation, focus, resilience |
|
||||
36
apps/skilltree/apps/web/package.json
Normal file
36
apps/skilltree/apps/web/package.json
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
{
|
||||
"name": "@skilltree/web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^5.0.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"@types/node": "^20.0.0",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-tailwind": "workspace:*",
|
||||
"@manacore/shared-theme": "workspace:*",
|
||||
"@manacore/shared-utils": "workspace:*",
|
||||
"idb": "^8.0.0",
|
||||
"lucide-svelte": "^0.556.0",
|
||||
"uuid": "^11.0.0"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
141
apps/skilltree/apps/web/src/app.css
Normal file
141
apps/skilltree/apps/web/src/app.css
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
@import 'tailwindcss';
|
||||
@import '@manacore/shared-tailwind/themes.css';
|
||||
|
||||
:root {
|
||||
/* SkillTree - Emerald/Green Theme (Growth & Progress) */
|
||||
--color-primary: #10b981;
|
||||
--color-primary-hover: #059669;
|
||||
--color-primary-light: #34d399;
|
||||
--color-primary-dark: #047857;
|
||||
|
||||
--color-secondary: #ecfdf5;
|
||||
--color-secondary-hover: #d1fae5;
|
||||
|
||||
--color-accent: #6ee7b7;
|
||||
--color-accent-hover: #34d399;
|
||||
|
||||
/* XP & Level Colors */
|
||||
--color-xp: #fbbf24;
|
||||
--color-xp-glow: rgba(251, 191, 36, 0.4);
|
||||
--color-level-up: #f59e0b;
|
||||
|
||||
/* Skill Levels */
|
||||
--color-level-0: #6b7280;
|
||||
--color-level-1: #3b82f6;
|
||||
--color-level-2: #8b5cf6;
|
||||
--color-level-3: #ec4899;
|
||||
--color-level-4: #f97316;
|
||||
--color-level-5: #fbbf24;
|
||||
|
||||
/* Branch Colors */
|
||||
--color-branch-intellect: #3b82f6;
|
||||
--color-branch-body: #ef4444;
|
||||
--color-branch-creativity: #ec4899;
|
||||
--color-branch-social: #8b5cf6;
|
||||
--color-branch-practical: #f97316;
|
||||
--color-branch-mindset: #10b981;
|
||||
}
|
||||
|
||||
/* Dark mode overrides */
|
||||
:root.dark {
|
||||
--color-secondary: #064e3b;
|
||||
--color-secondary-hover: #065f46;
|
||||
}
|
||||
|
||||
/* Skill card */
|
||||
.skill-card {
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.skill-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px -5px rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
/* XP Progress Bar */
|
||||
.xp-bar {
|
||||
background: linear-gradient(90deg, var(--color-primary) 0%, var(--color-xp) 100%);
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
|
||||
.xp-bar-container {
|
||||
background: rgba(107, 114, 128, 0.2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Level badge glow animation */
|
||||
@keyframes level-glow {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 5px var(--color-xp-glow);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 20px var(--color-xp-glow);
|
||||
}
|
||||
}
|
||||
|
||||
.level-badge-glow {
|
||||
animation: level-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Level up animation */
|
||||
@keyframes level-up {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.level-up-animation {
|
||||
animation: level-up 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
/* Branch indicator */
|
||||
.branch-indicator {
|
||||
width: 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Skill tree node */
|
||||
.tree-node {
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.tree-node:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.tree-node.locked {
|
||||
opacity: 0.5;
|
||||
filter: grayscale(0.8);
|
||||
}
|
||||
|
||||
/* Progress ring */
|
||||
.progress-ring {
|
||||
transition: stroke-dashoffset 0.5s ease;
|
||||
}
|
||||
|
||||
/* Add XP button pulse */
|
||||
@keyframes pulse-xp {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 10px rgba(16, 185, 129, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.pulse-xp:hover {
|
||||
animation: pulse-xp 1.5s infinite;
|
||||
}
|
||||
13
apps/skilltree/apps/web/src/app.d.ts
vendored
Normal file
13
apps/skilltree/apps/web/src/app.d.ts
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
27
apps/skilltree/apps/web/src/app.html
Normal file
27
apps/skilltree/apps/web/src/app.html
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
|
||||
<!-- PWA Manifest -->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
<!-- Theme Color -->
|
||||
<meta name="theme-color" content="#10b981" />
|
||||
<meta name="msapplication-TileColor" content="#10b981" />
|
||||
|
||||
<!-- PWA -->
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="SkillTree" />
|
||||
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
132
apps/skilltree/apps/web/src/lib/components/AddSkillModal.svelte
Normal file
132
apps/skilltree/apps/web/src/lib/components/AddSkillModal.svelte
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
<script lang="ts">
|
||||
import type { Skill, SkillBranch } from '$lib/types';
|
||||
import { BRANCH_INFO } from '$lib/types';
|
||||
import { X } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onSave: (skill: Partial<Skill>) => Promise<void>;
|
||||
}
|
||||
|
||||
let { onClose, onSave }: Props = $props();
|
||||
|
||||
let name = $state('');
|
||||
let description = $state('');
|
||||
let branch = $state<SkillBranch>('intellect');
|
||||
let saving = $state(false);
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
|
||||
saving = true;
|
||||
try {
|
||||
await onSave({
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
branch,
|
||||
});
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
onclick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div class="mx-4 w-full max-w-md rounded-2xl border border-gray-700 bg-gray-800 p-6 shadow-xl">
|
||||
<!-- Header -->
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold text-white">Neuer Skill</h2>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-700 hover:text-white"
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onsubmit={handleSubmit} class="space-y-4">
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label for="name" class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Name *
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
placeholder="z.B. TypeScript"
|
||||
class="w-full rounded-lg border border-gray-600 bg-gray-700 px-4 py-2 text-white placeholder-gray-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label for="description" class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Beschreibung
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
bind:value={description}
|
||||
placeholder="Worum geht es bei diesem Skill?"
|
||||
rows="3"
|
||||
class="w-full rounded-lg border border-gray-600 bg-gray-700 px-4 py-2 text-white placeholder-gray-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Branch -->
|
||||
<div>
|
||||
<label for="branch" class="mb-2 block text-sm font-medium text-gray-300">
|
||||
Kategorie
|
||||
</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{#each Object.entries(BRANCH_INFO) as [key, info]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (branch = key as SkillBranch)}
|
||||
class="flex items-center gap-2 rounded-lg border px-3 py-2 text-left text-sm transition-colors {branch === key
|
||||
? 'border-emerald-500 bg-emerald-500/20 text-white'
|
||||
: 'border-gray-600 bg-gray-700/50 text-gray-300 hover:border-gray-500'}"
|
||||
>
|
||||
<span
|
||||
class="h-3 w-3 rounded-full"
|
||||
style="background-color: {info.color}"
|
||||
></span>
|
||||
{info.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="flex-1 rounded-lg border border-gray-600 bg-transparent px-4 py-2 font-medium text-gray-300 transition-colors hover:bg-gray-700"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!name.trim() || saving}
|
||||
class="flex-1 rounded-lg bg-emerald-600 px-4 py-2 font-medium text-white transition-colors hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Speichern...' : 'Erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
172
apps/skilltree/apps/web/src/lib/components/AddXpModal.svelte
Normal file
172
apps/skilltree/apps/web/src/lib/components/AddXpModal.svelte
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
<script lang="ts">
|
||||
import type { Skill } from '$lib/types';
|
||||
import { LEVEL_NAMES } from '$lib/types';
|
||||
import { X, Zap, Clock, Star } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
skill: Skill;
|
||||
onClose: () => void;
|
||||
onSave: (xp: number, description: string, duration?: number) => Promise<void>;
|
||||
}
|
||||
|
||||
let { skill, onClose, onSave }: Props = $props();
|
||||
|
||||
let xp = $state(10);
|
||||
let description = $state('');
|
||||
let duration = $state<number | undefined>(undefined);
|
||||
let saving = $state(false);
|
||||
|
||||
// Quick XP presets
|
||||
const xpPresets = [
|
||||
{ label: '+5', value: 5, desc: 'Kurz geübt' },
|
||||
{ label: '+10', value: 10, desc: 'Normale Session' },
|
||||
{ label: '+25', value: 25, desc: 'Intensive Session' },
|
||||
{ label: '+50', value: 50, desc: 'Großer Fortschritt' },
|
||||
{ label: '+100', value: 100, desc: 'Meilenstein erreicht' },
|
||||
];
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
if (xp <= 0) return;
|
||||
|
||||
saving = true;
|
||||
try {
|
||||
await onSave(xp, description || `+${xp} XP`, duration);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function selectPreset(preset: { value: number; desc: string }) {
|
||||
xp = preset.value;
|
||||
if (!description) {
|
||||
description = preset.desc;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
onclick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div class="mx-4 w-full max-w-md rounded-2xl border border-gray-700 bg-gray-800 p-6 shadow-xl">
|
||||
<!-- Header -->
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-white">XP hinzufügen</h2>
|
||||
<p class="text-sm text-gray-400">{skill.name} (Lvl {skill.level})</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-700 hover:text-white"
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onsubmit={handleSubmit} class="space-y-4">
|
||||
<!-- Quick XP Presets -->
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-300">
|
||||
Schnellauswahl
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each xpPresets as preset}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selectPreset(preset)}
|
||||
class="rounded-lg border px-3 py-1.5 text-sm font-medium transition-colors {xp === preset.value
|
||||
? 'border-emerald-500 bg-emerald-500/20 text-emerald-400'
|
||||
: 'border-gray-600 bg-gray-700/50 text-gray-300 hover:border-gray-500'}"
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom XP -->
|
||||
<div>
|
||||
<label for="xp" class="mb-1 block text-sm font-medium text-gray-300">
|
||||
<Zap class="mr-1 inline h-4 w-4 text-yellow-500" />
|
||||
XP Menge
|
||||
</label>
|
||||
<input
|
||||
id="xp"
|
||||
type="number"
|
||||
bind:value={xp}
|
||||
min="1"
|
||||
max="1000"
|
||||
class="w-full rounded-lg border border-gray-600 bg-gray-700 px-4 py-2 text-white placeholder-gray-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label for="description" class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Was hast du gemacht?
|
||||
</label>
|
||||
<input
|
||||
id="description"
|
||||
type="text"
|
||||
bind:value={description}
|
||||
placeholder="z.B. Tutorial abgeschlossen"
|
||||
class="w-full rounded-lg border border-gray-600 bg-gray-700 px-4 py-2 text-white placeholder-gray-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Duration (optional) -->
|
||||
<div>
|
||||
<label for="duration" class="mb-1 block text-sm font-medium text-gray-300">
|
||||
<Clock class="mr-1 inline h-4 w-4 text-gray-400" />
|
||||
Dauer (optional, Minuten)
|
||||
</label>
|
||||
<input
|
||||
id="duration"
|
||||
type="number"
|
||||
bind:value={duration}
|
||||
min="1"
|
||||
placeholder="z.B. 30"
|
||||
class="w-full rounded-lg border border-gray-600 bg-gray-700 px-4 py-2 text-white placeholder-gray-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Preview -->
|
||||
<div class="rounded-lg bg-gray-700/50 p-3">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-gray-400">Vorschau</span>
|
||||
<span class="font-medium text-emerald-400">+{xp} XP</span>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500">
|
||||
Neuer Stand: {(skill.totalXp + xp).toLocaleString()} XP
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="flex-1 rounded-lg border border-gray-600 bg-transparent px-4 py-2 font-medium text-gray-300 transition-colors hover:bg-gray-700"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={xp <= 0 || saving}
|
||||
class="flex-1 rounded-lg bg-emerald-600 px-4 py-2 font-medium text-white transition-colors hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Speichern...' : 'XP vergeben'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
102
apps/skilltree/apps/web/src/lib/components/SkillCard.svelte
Normal file
102
apps/skilltree/apps/web/src/lib/components/SkillCard.svelte
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
<script lang="ts">
|
||||
import type { Skill } from '$lib/types';
|
||||
import { BRANCH_INFO, LEVEL_NAMES, xpProgress, xpForNextLevel } from '$lib/types';
|
||||
import { Plus, Trash2, Edit, Star } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
skill: Skill;
|
||||
onAddXp: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
let { skill, onAddXp, onEdit, onDelete }: Props = $props();
|
||||
|
||||
const branchInfo = $derived(BRANCH_INFO[skill.branch]);
|
||||
const levelName = $derived(LEVEL_NAMES[skill.level]);
|
||||
const progress = $derived(xpProgress(skill.totalXp, skill.level));
|
||||
const nextLevelXp = $derived(xpForNextLevel(skill.level));
|
||||
const isMaxLevel = $derived(skill.level >= LEVEL_NAMES.length - 1);
|
||||
|
||||
function getLevelColor(level: number): string {
|
||||
const colors = [
|
||||
'text-gray-400',
|
||||
'text-blue-400',
|
||||
'text-purple-400',
|
||||
'text-pink-400',
|
||||
'text-orange-400',
|
||||
'text-yellow-400',
|
||||
];
|
||||
return colors[level] ?? colors[0];
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="skill-card group relative overflow-hidden rounded-xl border border-gray-700 bg-gray-800/50 p-4">
|
||||
<!-- Branch Indicator -->
|
||||
<div
|
||||
class="branch-indicator absolute left-0 top-0 h-full"
|
||||
style="background-color: {branchInfo.color}"
|
||||
></div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mb-3 flex items-start justify-between pl-3">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-white">{skill.name}</h3>
|
||||
<p class="text-sm text-gray-400">{branchInfo.name}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
{#each Array(skill.level) as _, i}
|
||||
<Star class="h-4 w-4 fill-yellow-500 text-yellow-500" />
|
||||
{/each}
|
||||
{#each Array(5 - skill.level) as _, i}
|
||||
<Star class="h-4 w-4 text-gray-600" />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Level Badge -->
|
||||
<div class="mb-3 pl-3">
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-gray-700/50 px-3 py-1 text-sm {getLevelColor(skill.level)}">
|
||||
Lvl {skill.level} - {levelName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- XP Progress -->
|
||||
<div class="mb-4 pl-3">
|
||||
<div class="mb-1 flex justify-between text-sm">
|
||||
<span class="text-gray-400">XP</span>
|
||||
<span class="text-gray-300">
|
||||
{skill.totalXp.toLocaleString()}
|
||||
{#if !isMaxLevel}
|
||||
/ {nextLevelXp.toLocaleString()}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<div class="xp-bar-container h-2 rounded-full">
|
||||
<div class="xp-bar h-full rounded-full" style="width: {progress}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
{#if skill.description}
|
||||
<p class="mb-4 pl-3 text-sm text-gray-400 line-clamp-2">{skill.description}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2 pl-3">
|
||||
<button
|
||||
onclick={onAddXp}
|
||||
class="pulse-xp flex flex-1 items-center justify-center gap-2 rounded-lg bg-emerald-600/20 px-3 py-2 text-sm font-medium text-emerald-400 transition-colors hover:bg-emerald-600/30"
|
||||
>
|
||||
<Plus class="h-4 w-4" />
|
||||
XP hinzufügen
|
||||
</button>
|
||||
<button
|
||||
onclick={onDelete}
|
||||
class="rounded-lg bg-red-600/20 p-2 text-red-400 opacity-0 transition-all hover:bg-red-600/30 group-hover:opacity-100"
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
<script lang="ts">
|
||||
import { skillStore } from '$lib/stores/skills.svelte';
|
||||
import { Trophy, Zap, Target, Flame } from 'lucide-svelte';
|
||||
</script>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<!-- Total XP -->
|
||||
<div class="rounded-xl border border-gray-700 bg-gray-800/50 p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-yellow-500/20">
|
||||
<Zap class="h-6 w-6 text-yellow-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">Gesamt-XP</p>
|
||||
<p class="text-2xl font-bold text-white">
|
||||
{skillStore.userStats.totalXp.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Skills -->
|
||||
<div class="rounded-xl border border-gray-700 bg-gray-800/50 p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-emerald-500/20">
|
||||
<Target class="h-6 w-6 text-emerald-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">Skills</p>
|
||||
<p class="text-2xl font-bold text-white">
|
||||
{skillStore.userStats.totalSkills}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Highest Level -->
|
||||
<div class="rounded-xl border border-gray-700 bg-gray-800/50 p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-purple-500/20">
|
||||
<Trophy class="h-6 w-6 text-purple-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">Höchstes Level</p>
|
||||
<p class="text-2xl font-bold text-white">
|
||||
{skillStore.userStats.highestLevel}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Streak -->
|
||||
<div class="rounded-xl border border-gray-700 bg-gray-800/50 p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-orange-500/20">
|
||||
<Flame class="h-6 w-6 text-orange-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">Streak</p>
|
||||
<p class="text-2xl font-bold text-white">
|
||||
{skillStore.userStats.streakDays} Tage
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
232
apps/skilltree/apps/web/src/lib/services/storage.ts
Normal file
232
apps/skilltree/apps/web/src/lib/services/storage.ts
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import { openDB, type DBSchema, type IDBPDatabase } from 'idb';
|
||||
import type { Skill, Activity, UserStats } from '$lib/types';
|
||||
|
||||
interface SkillTreeDB extends DBSchema {
|
||||
skills: {
|
||||
key: string;
|
||||
value: Skill;
|
||||
indexes: {
|
||||
'by-branch': string;
|
||||
'by-parent': string | null;
|
||||
'by-level': number;
|
||||
};
|
||||
};
|
||||
activities: {
|
||||
key: string;
|
||||
value: Activity;
|
||||
indexes: {
|
||||
'by-skill': string;
|
||||
'by-timestamp': string;
|
||||
};
|
||||
};
|
||||
stats: {
|
||||
key: 'user-stats';
|
||||
value: UserStats;
|
||||
};
|
||||
}
|
||||
|
||||
const DB_NAME = 'skilltree-db';
|
||||
const DB_VERSION = 1;
|
||||
|
||||
let dbPromise: Promise<IDBPDatabase<SkillTreeDB>> | null = null;
|
||||
|
||||
function getDB(): Promise<IDBPDatabase<SkillTreeDB>> {
|
||||
if (!dbPromise) {
|
||||
dbPromise = openDB<SkillTreeDB>(DB_NAME, DB_VERSION, {
|
||||
upgrade(db) {
|
||||
// Skills store
|
||||
if (!db.objectStoreNames.contains('skills')) {
|
||||
const skillStore = db.createObjectStore('skills', { keyPath: 'id' });
|
||||
skillStore.createIndex('by-branch', 'branch');
|
||||
skillStore.createIndex('by-parent', 'parentId');
|
||||
skillStore.createIndex('by-level', 'level');
|
||||
}
|
||||
|
||||
// Activities store
|
||||
if (!db.objectStoreNames.contains('activities')) {
|
||||
const activityStore = db.createObjectStore('activities', { keyPath: 'id' });
|
||||
activityStore.createIndex('by-skill', 'skillId');
|
||||
activityStore.createIndex('by-timestamp', 'timestamp');
|
||||
}
|
||||
|
||||
// Stats store
|
||||
if (!db.objectStoreNames.contains('stats')) {
|
||||
db.createObjectStore('stats');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
// Skills CRUD
|
||||
export async function getAllSkills(): Promise<Skill[]> {
|
||||
const db = await getDB();
|
||||
return db.getAll('skills');
|
||||
}
|
||||
|
||||
export async function getSkillById(id: string): Promise<Skill | undefined> {
|
||||
const db = await getDB();
|
||||
return db.get('skills', id);
|
||||
}
|
||||
|
||||
export async function getSkillsByBranch(branch: string): Promise<Skill[]> {
|
||||
const db = await getDB();
|
||||
return db.getAllFromIndex('skills', 'by-branch', branch);
|
||||
}
|
||||
|
||||
export async function getChildSkills(parentId: string): Promise<Skill[]> {
|
||||
const db = await getDB();
|
||||
return db.getAllFromIndex('skills', 'by-parent', parentId);
|
||||
}
|
||||
|
||||
export async function saveSkill(skill: Skill): Promise<void> {
|
||||
const db = await getDB();
|
||||
skill.updatedAt = new Date().toISOString();
|
||||
await db.put('skills', skill);
|
||||
}
|
||||
|
||||
export async function deleteSkill(id: string): Promise<void> {
|
||||
const db = await getDB();
|
||||
// Delete skill and all its activities
|
||||
const activities = await db.getAllFromIndex('activities', 'by-skill', id);
|
||||
const tx = db.transaction(['skills', 'activities'], 'readwrite');
|
||||
await Promise.all([
|
||||
tx.objectStore('skills').delete(id),
|
||||
...activities.map((a) => tx.objectStore('activities').delete(a.id)),
|
||||
]);
|
||||
await tx.done;
|
||||
}
|
||||
|
||||
// Activities CRUD
|
||||
export async function getAllActivities(): Promise<Activity[]> {
|
||||
const db = await getDB();
|
||||
return db.getAll('activities');
|
||||
}
|
||||
|
||||
export async function getActivitiesBySkill(skillId: string): Promise<Activity[]> {
|
||||
const db = await getDB();
|
||||
return db.getAllFromIndex('activities', 'by-skill', skillId);
|
||||
}
|
||||
|
||||
export async function getRecentActivities(limit = 10): Promise<Activity[]> {
|
||||
const db = await getDB();
|
||||
const all = await db.getAllFromIndex('activities', 'by-timestamp');
|
||||
return all.reverse().slice(0, limit);
|
||||
}
|
||||
|
||||
export async function saveActivity(activity: Activity): Promise<void> {
|
||||
const db = await getDB();
|
||||
await db.put('activities', activity);
|
||||
}
|
||||
|
||||
export async function deleteActivity(id: string): Promise<void> {
|
||||
const db = await getDB();
|
||||
await db.delete('activities', id);
|
||||
}
|
||||
|
||||
// User Stats
|
||||
export async function getUserStats(): Promise<UserStats> {
|
||||
const db = await getDB();
|
||||
const stats = await db.get('stats', 'user-stats');
|
||||
return (
|
||||
stats ?? {
|
||||
totalXp: 0,
|
||||
totalSkills: 0,
|
||||
highestLevel: 0,
|
||||
streakDays: 0,
|
||||
lastActivityDate: null,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function saveUserStats(stats: UserStats): Promise<void> {
|
||||
const db = await getDB();
|
||||
await db.put('stats', stats, 'user-stats');
|
||||
}
|
||||
|
||||
// Utility: Recalculate stats from all skills
|
||||
export async function recalculateStats(): Promise<UserStats> {
|
||||
const skills = await getAllSkills();
|
||||
const activities = await getAllActivities();
|
||||
|
||||
const stats: UserStats = {
|
||||
totalXp: skills.reduce((sum, s) => sum + s.totalXp, 0),
|
||||
totalSkills: skills.length,
|
||||
highestLevel: skills.reduce((max, s) => Math.max(max, s.level), 0),
|
||||
streakDays: calculateStreak(activities),
|
||||
lastActivityDate: activities.length > 0 ? activities[activities.length - 1].timestamp : null,
|
||||
};
|
||||
|
||||
await saveUserStats(stats);
|
||||
return stats;
|
||||
}
|
||||
|
||||
function calculateStreak(activities: Activity[]): number {
|
||||
if (activities.length === 0) return 0;
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const sortedDates = activities
|
||||
.map((a) => {
|
||||
const d = new Date(a.timestamp);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d.getTime();
|
||||
})
|
||||
.filter((v, i, a) => a.indexOf(v) === i) // unique dates
|
||||
.sort((a, b) => b - a); // newest first
|
||||
|
||||
let streak = 0;
|
||||
let expectedDate = today.getTime();
|
||||
|
||||
for (const date of sortedDates) {
|
||||
if (date === expectedDate || date === expectedDate - 86400000) {
|
||||
streak++;
|
||||
expectedDate = date - 86400000;
|
||||
} else if (date < expectedDate - 86400000) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return streak;
|
||||
}
|
||||
|
||||
// Export all data (for backup)
|
||||
export async function exportData(): Promise<{
|
||||
skills: Skill[];
|
||||
activities: Activity[];
|
||||
stats: UserStats;
|
||||
}> {
|
||||
const [skills, activities, stats] = await Promise.all([
|
||||
getAllSkills(),
|
||||
getAllActivities(),
|
||||
getUserStats(),
|
||||
]);
|
||||
return { skills, activities, stats };
|
||||
}
|
||||
|
||||
// Import data (restore backup)
|
||||
export async function importData(data: {
|
||||
skills: Skill[];
|
||||
activities: Activity[];
|
||||
stats: UserStats;
|
||||
}): Promise<void> {
|
||||
const db = await getDB();
|
||||
const tx = db.transaction(['skills', 'activities', 'stats'], 'readwrite');
|
||||
|
||||
// Clear existing data
|
||||
await tx.objectStore('skills').clear();
|
||||
await tx.objectStore('activities').clear();
|
||||
|
||||
// Import new data
|
||||
for (const skill of data.skills) {
|
||||
await tx.objectStore('skills').put(skill);
|
||||
}
|
||||
for (const activity of data.activities) {
|
||||
await tx.objectStore('activities').put(activity);
|
||||
}
|
||||
await tx.objectStore('stats').put(data.stats, 'user-stats');
|
||||
|
||||
await tx.done;
|
||||
}
|
||||
176
apps/skilltree/apps/web/src/lib/stores/skills.svelte.ts
Normal file
176
apps/skilltree/apps/web/src/lib/stores/skills.svelte.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import type { Skill, Activity, UserStats, SkillBranch } from '$lib/types';
|
||||
import {
|
||||
calculateLevel,
|
||||
createDefaultSkill,
|
||||
createActivity,
|
||||
BRANCH_INFO,
|
||||
} from '$lib/types';
|
||||
import * as storage from '$lib/services/storage';
|
||||
|
||||
// Reactive state using Svelte 5 runes
|
||||
let skills = $state<Skill[]>([]);
|
||||
let activities = $state<Activity[]>([]);
|
||||
let userStats = $state<UserStats>({
|
||||
totalXp: 0,
|
||||
totalSkills: 0,
|
||||
highestLevel: 0,
|
||||
streakDays: 0,
|
||||
lastActivityDate: null,
|
||||
});
|
||||
let isLoading = $state(true);
|
||||
let initialized = $state(false);
|
||||
|
||||
// Derived values
|
||||
const skillsByBranch = $derived(() => {
|
||||
const grouped: Record<SkillBranch, Skill[]> = {
|
||||
intellect: [],
|
||||
body: [],
|
||||
creativity: [],
|
||||
social: [],
|
||||
practical: [],
|
||||
mindset: [],
|
||||
custom: [],
|
||||
};
|
||||
for (const skill of skills) {
|
||||
grouped[skill.branch].push(skill);
|
||||
}
|
||||
return grouped;
|
||||
});
|
||||
|
||||
const topSkills = $derived(() => {
|
||||
return [...skills].sort((a, b) => b.totalXp - a.totalXp).slice(0, 5);
|
||||
});
|
||||
|
||||
const recentActivities = $derived(() => {
|
||||
return [...activities].sort((a, b) =>
|
||||
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
||||
).slice(0, 10);
|
||||
});
|
||||
|
||||
const branchStats = $derived(() => {
|
||||
const stats: Record<SkillBranch, { count: number; totalXp: number; avgLevel: number }> = {} as any;
|
||||
for (const branch of Object.keys(BRANCH_INFO) as SkillBranch[]) {
|
||||
const branchSkills = skills.filter((s) => s.branch === branch);
|
||||
stats[branch] = {
|
||||
count: branchSkills.length,
|
||||
totalXp: branchSkills.reduce((sum, s) => sum + s.totalXp, 0),
|
||||
avgLevel:
|
||||
branchSkills.length > 0
|
||||
? branchSkills.reduce((sum, s) => sum + s.level, 0) / branchSkills.length
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
return stats;
|
||||
});
|
||||
|
||||
// Actions
|
||||
async function initialize() {
|
||||
if (initialized) return;
|
||||
|
||||
isLoading = true;
|
||||
try {
|
||||
const [loadedSkills, loadedActivities, loadedStats] = await Promise.all([
|
||||
storage.getAllSkills(),
|
||||
storage.getAllActivities(),
|
||||
storage.getUserStats(),
|
||||
]);
|
||||
skills = loadedSkills;
|
||||
activities = loadedActivities;
|
||||
userStats = loadedStats;
|
||||
initialized = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize skills store:', error);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addSkill(data: Partial<Skill>): Promise<Skill> {
|
||||
const skill = createDefaultSkill(data);
|
||||
await storage.saveSkill(skill);
|
||||
skills = [...skills, skill];
|
||||
await updateStats();
|
||||
return skill;
|
||||
}
|
||||
|
||||
async function updateSkill(id: string, updates: Partial<Skill>): Promise<void> {
|
||||
const index = skills.findIndex((s) => s.id === id);
|
||||
if (index === -1) return;
|
||||
|
||||
const updatedSkill = { ...skills[index], ...updates, updatedAt: new Date().toISOString() };
|
||||
await storage.saveSkill(updatedSkill);
|
||||
skills = [...skills.slice(0, index), updatedSkill, ...skills.slice(index + 1)];
|
||||
await updateStats();
|
||||
}
|
||||
|
||||
async function deleteSkill(id: string): Promise<void> {
|
||||
await storage.deleteSkill(id);
|
||||
skills = skills.filter((s) => s.id !== id);
|
||||
activities = activities.filter((a) => a.skillId !== id);
|
||||
await updateStats();
|
||||
}
|
||||
|
||||
async function addXp(skillId: string, xp: number, description: string, duration?: number): Promise<{ leveledUp: boolean; newLevel: number }> {
|
||||
const index = skills.findIndex((s) => s.id === skillId);
|
||||
if (index === -1) return { leveledUp: false, newLevel: 0 };
|
||||
|
||||
const skill = skills[index];
|
||||
const newTotalXp = skill.totalXp + xp;
|
||||
const newCurrentXp = skill.currentXp + xp;
|
||||
const newLevel = calculateLevel(newTotalXp);
|
||||
const leveledUp = newLevel > skill.level;
|
||||
|
||||
const updatedSkill: Skill = {
|
||||
...skill,
|
||||
totalXp: newTotalXp,
|
||||
currentXp: newCurrentXp,
|
||||
level: newLevel,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const activity = createActivity(skillId, xp, description, duration);
|
||||
|
||||
await Promise.all([
|
||||
storage.saveSkill(updatedSkill),
|
||||
storage.saveActivity(activity),
|
||||
]);
|
||||
|
||||
skills = [...skills.slice(0, index), updatedSkill, ...skills.slice(index + 1)];
|
||||
activities = [...activities, activity];
|
||||
await updateStats();
|
||||
|
||||
return { leveledUp, newLevel };
|
||||
}
|
||||
|
||||
async function updateStats(): Promise<void> {
|
||||
userStats = await storage.recalculateStats();
|
||||
}
|
||||
|
||||
function getSkill(id: string): Skill | undefined {
|
||||
return skills.find((s) => s.id === id);
|
||||
}
|
||||
|
||||
function getSkillActivities(skillId: string): Activity[] {
|
||||
return activities.filter((a) => a.skillId === skillId);
|
||||
}
|
||||
|
||||
// Export store as object with getters for reactive access
|
||||
export const skillStore = {
|
||||
get skills() { return skills; },
|
||||
get activities() { return activities; },
|
||||
get userStats() { return userStats; },
|
||||
get isLoading() { return isLoading; },
|
||||
get initialized() { return initialized; },
|
||||
get skillsByBranch() { return skillsByBranch; },
|
||||
get topSkills() { return topSkills; },
|
||||
get recentActivities() { return recentActivities; },
|
||||
get branchStats() { return branchStats; },
|
||||
|
||||
initialize,
|
||||
addSkill,
|
||||
updateSkill,
|
||||
deleteSkill,
|
||||
addXp,
|
||||
getSkill,
|
||||
getSkillActivities,
|
||||
};
|
||||
164
apps/skilltree/apps/web/src/lib/types/index.ts
Normal file
164
apps/skilltree/apps/web/src/lib/types/index.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
// Skill Tree Types
|
||||
|
||||
export type SkillBranch =
|
||||
| 'intellect'
|
||||
| 'body'
|
||||
| 'creativity'
|
||||
| 'social'
|
||||
| 'practical'
|
||||
| 'mindset'
|
||||
| 'custom';
|
||||
|
||||
export interface Skill {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
branch: SkillBranch;
|
||||
parentId: string | null;
|
||||
icon: string;
|
||||
color: string | null;
|
||||
currentXp: number;
|
||||
totalXp: number;
|
||||
level: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Activity {
|
||||
id: string;
|
||||
skillId: string;
|
||||
xpEarned: number;
|
||||
description: string;
|
||||
duration: number | null; // minutes
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface UserStats {
|
||||
totalXp: number;
|
||||
totalSkills: number;
|
||||
highestLevel: number;
|
||||
streakDays: number;
|
||||
lastActivityDate: string | null;
|
||||
}
|
||||
|
||||
// Level thresholds (XP needed for each level)
|
||||
export const LEVEL_THRESHOLDS = [0, 100, 500, 1500, 4000, 10000] as const;
|
||||
|
||||
export const LEVEL_NAMES = [
|
||||
'Unbekannt',
|
||||
'Anfänger',
|
||||
'Fortgeschritten',
|
||||
'Kompetent',
|
||||
'Experte',
|
||||
'Meister',
|
||||
] as const;
|
||||
|
||||
export const BRANCH_INFO: Record<
|
||||
SkillBranch,
|
||||
{ name: string; icon: string; color: string; description: string }
|
||||
> = {
|
||||
intellect: {
|
||||
name: 'Intellekt',
|
||||
icon: 'brain',
|
||||
color: 'var(--color-branch-intellect)',
|
||||
description: 'Wissen, Sprachen, Wissenschaft',
|
||||
},
|
||||
body: {
|
||||
name: 'Körper',
|
||||
icon: 'dumbbell',
|
||||
color: 'var(--color-branch-body)',
|
||||
description: 'Fitness, Sport, Gesundheit',
|
||||
},
|
||||
creativity: {
|
||||
name: 'Kreativität',
|
||||
icon: 'palette',
|
||||
color: 'var(--color-branch-creativity)',
|
||||
description: 'Kunst, Musik, Schreiben',
|
||||
},
|
||||
social: {
|
||||
name: 'Sozial',
|
||||
icon: 'users',
|
||||
color: 'var(--color-branch-social)',
|
||||
description: 'Kommunikation, Leadership, Empathie',
|
||||
},
|
||||
practical: {
|
||||
name: 'Praktisch',
|
||||
icon: 'wrench',
|
||||
color: 'var(--color-branch-practical)',
|
||||
description: 'Handwerk, Kochen, Technologie',
|
||||
},
|
||||
mindset: {
|
||||
name: 'Mindset',
|
||||
icon: 'heart',
|
||||
color: 'var(--color-branch-mindset)',
|
||||
description: 'Meditation, Fokus, Resilienz',
|
||||
},
|
||||
custom: {
|
||||
name: 'Eigene',
|
||||
icon: 'star',
|
||||
color: 'var(--color-primary)',
|
||||
description: 'Eigene Kategorien',
|
||||
},
|
||||
};
|
||||
|
||||
// Helper functions
|
||||
export function calculateLevel(xp: number): number {
|
||||
for (let i = LEVEL_THRESHOLDS.length - 1; i >= 0; i--) {
|
||||
if (xp >= LEVEL_THRESHOLDS[i]) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function xpForNextLevel(currentLevel: number): number {
|
||||
if (currentLevel >= LEVEL_THRESHOLDS.length - 1) {
|
||||
return Infinity;
|
||||
}
|
||||
return LEVEL_THRESHOLDS[currentLevel + 1];
|
||||
}
|
||||
|
||||
export function xpProgress(xp: number, level: number): number {
|
||||
if (level >= LEVEL_THRESHOLDS.length - 1) {
|
||||
return 100;
|
||||
}
|
||||
const currentThreshold = LEVEL_THRESHOLDS[level];
|
||||
const nextThreshold = LEVEL_THRESHOLDS[level + 1];
|
||||
const progress = ((xp - currentThreshold) / (nextThreshold - currentThreshold)) * 100;
|
||||
return Math.min(100, Math.max(0, progress));
|
||||
}
|
||||
|
||||
export function createDefaultSkill(partial: Partial<Skill> = {}): Skill {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
name: '',
|
||||
description: '',
|
||||
branch: 'custom',
|
||||
parentId: null,
|
||||
icon: 'star',
|
||||
color: null,
|
||||
currentXp: 0,
|
||||
totalXp: 0,
|
||||
level: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
export function createActivity(
|
||||
skillId: string,
|
||||
xpEarned: number,
|
||||
description: string,
|
||||
duration?: number
|
||||
): Activity {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
skillId,
|
||||
xpEarned,
|
||||
description,
|
||||
duration: duration ?? null,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
32
apps/skilltree/apps/web/src/routes/+layout.svelte
Normal file
32
apps/skilltree/apps/web/src/routes/+layout.svelte
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { onMount } from 'svelte';
|
||||
import { skillStore } from '$lib/stores/skills.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let loading = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
await skillStore.initialize();
|
||||
loading = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>SkillTree - Level Up Your Life</title>
|
||||
<meta name="description" content="Track your skills like a game. Level up in real life." />
|
||||
</svelte:head>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex min-h-screen items-center justify-center bg-gray-900">
|
||||
<div class="text-center">
|
||||
<div class="mb-4 text-6xl">🌳</div>
|
||||
<div class="text-xl text-gray-300">Loading SkillTree...</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="min-h-screen bg-gray-900 text-gray-100">
|
||||
{@render children()}
|
||||
</div>
|
||||
{/if}
|
||||
178
apps/skilltree/apps/web/src/routes/+page.svelte
Normal file
178
apps/skilltree/apps/web/src/routes/+page.svelte
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
<script lang="ts">
|
||||
import { skillStore } from '$lib/stores/skills.svelte';
|
||||
import { BRANCH_INFO, LEVEL_NAMES, xpProgress, xpForNextLevel } from '$lib/types';
|
||||
import type { Skill, SkillBranch } from '$lib/types';
|
||||
import SkillCard from '$lib/components/SkillCard.svelte';
|
||||
import AddSkillModal from '$lib/components/AddSkillModal.svelte';
|
||||
import AddXpModal from '$lib/components/AddXpModal.svelte';
|
||||
import StatsOverview from '$lib/components/StatsOverview.svelte';
|
||||
import {
|
||||
Plus,
|
||||
TreeDeciduous,
|
||||
Trophy,
|
||||
Zap,
|
||||
TrendingUp,
|
||||
} from 'lucide-svelte';
|
||||
|
||||
let showAddSkillModal = $state(false);
|
||||
let showAddXpModal = $state(false);
|
||||
let selectedSkillForXp = $state<Skill | null>(null);
|
||||
let selectedBranch = $state<SkillBranch | 'all'>('all');
|
||||
|
||||
const filteredSkills = $derived(() => {
|
||||
if (selectedBranch === 'all') return skillStore.skills;
|
||||
return skillStore.skills.filter((s) => s.branch === selectedBranch);
|
||||
});
|
||||
|
||||
function openAddXpModal(skill: Skill) {
|
||||
selectedSkillForXp = skill;
|
||||
showAddXpModal = true;
|
||||
}
|
||||
|
||||
function closeAddXpModal() {
|
||||
showAddXpModal = false;
|
||||
selectedSkillForXp = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen">
|
||||
<!-- Header -->
|
||||
<header class="border-b border-gray-800 bg-gray-900/80 backdrop-blur-sm sticky top-0 z-40">
|
||||
<div class="mx-auto max-w-7xl px-4 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<TreeDeciduous class="h-8 w-8 text-emerald-500" />
|
||||
<h1 class="text-2xl font-bold text-white">SkillTree</h1>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => (showAddSkillModal = true)}
|
||||
class="flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-2 font-medium text-white transition-colors hover:bg-emerald-500"
|
||||
>
|
||||
<Plus class="h-5 w-5" />
|
||||
Skill hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="mx-auto max-w-7xl px-4 py-8">
|
||||
<!-- Stats Overview -->
|
||||
<StatsOverview />
|
||||
|
||||
<!-- Branch Filter -->
|
||||
<div class="mb-6 mt-8">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
onclick={() => (selectedBranch = 'all')}
|
||||
class="rounded-full px-4 py-2 text-sm font-medium transition-colors {selectedBranch === 'all'
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'}"
|
||||
>
|
||||
Alle ({skillStore.skills.length})
|
||||
</button>
|
||||
{#each Object.entries(BRANCH_INFO) as [branch, info]}
|
||||
{@const count = skillStore.skills.filter((s) => s.branch === branch).length}
|
||||
{#if count > 0 || branch !== 'custom'}
|
||||
<button
|
||||
onclick={() => (selectedBranch = branch as SkillBranch)}
|
||||
class="rounded-full px-4 py-2 text-sm font-medium transition-colors {selectedBranch === branch
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'}"
|
||||
>
|
||||
{info.name} ({count})
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Skills Grid -->
|
||||
{#if filteredSkills().length === 0}
|
||||
<div class="mt-16 text-center">
|
||||
<div class="mx-auto mb-6 flex h-24 w-24 items-center justify-center rounded-full bg-gray-800">
|
||||
<TreeDeciduous class="h-12 w-12 text-gray-600" />
|
||||
</div>
|
||||
<h2 class="mb-2 text-xl font-semibold text-gray-300">Noch keine Skills</h2>
|
||||
<p class="mb-6 text-gray-500">
|
||||
Füge deinen ersten Skill hinzu und beginne dein Abenteuer!
|
||||
</p>
|
||||
<button
|
||||
onclick={() => (showAddSkillModal = true)}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-6 py-3 font-medium text-white transition-colors hover:bg-emerald-500"
|
||||
>
|
||||
<Plus class="h-5 w-5" />
|
||||
Ersten Skill erstellen
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each filteredSkills() as skill (skill.id)}
|
||||
<SkillCard
|
||||
{skill}
|
||||
onAddXp={() => openAddXpModal(skill)}
|
||||
onEdit={() => {}}
|
||||
onDelete={() => skillStore.deleteSkill(skill.id)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Recent Activity -->
|
||||
{#if skillStore.recentActivities().length > 0}
|
||||
<div class="mt-12">
|
||||
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-white">
|
||||
<Zap class="h-5 w-5 text-yellow-500" />
|
||||
Letzte Aktivitäten
|
||||
</h2>
|
||||
<div class="space-y-2">
|
||||
{#each skillStore.recentActivities().slice(0, 5) as activity}
|
||||
{@const skill = skillStore.getSkill(activity.skillId)}
|
||||
{#if skill}
|
||||
<div class="flex items-center justify-between rounded-lg bg-gray-800/50 px-4 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-emerald-900/50 text-emerald-400">
|
||||
+{activity.xpEarned}
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium text-white">{skill.name}</span>
|
||||
<span class="text-gray-400"> - {activity.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm text-gray-500">
|
||||
{new Date(activity.timestamp).toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Modals -->
|
||||
{#if showAddSkillModal}
|
||||
<AddSkillModal
|
||||
onClose={() => (showAddSkillModal = false)}
|
||||
onSave={async (skill) => {
|
||||
await skillStore.addSkill(skill);
|
||||
showAddSkillModal = false;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showAddXpModal && selectedSkillForXp}
|
||||
<AddXpModal
|
||||
skill={selectedSkillForXp}
|
||||
onClose={closeAddXpModal}
|
||||
onSave={async (xp, description, duration) => {
|
||||
if (selectedSkillForXp) {
|
||||
const result = await skillStore.addXp(selectedSkillForXp.id, xp, description, duration);
|
||||
if (result.leveledUp) {
|
||||
// Could show a level-up celebration here
|
||||
}
|
||||
}
|
||||
closeAddXpModal();
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
21
apps/skilltree/apps/web/static/manifest.json
Normal file
21
apps/skilltree/apps/web/static/manifest.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "SkillTree",
|
||||
"short_name": "SkillTree",
|
||||
"description": "Track your skills like a game. Level up in real life.",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#111827",
|
||||
"theme_color": "#10b981",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
14
apps/skilltree/apps/web/svelte.config.js
Normal file
14
apps/skilltree/apps/web/svelte.config.js
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import adapter from '@sveltejs/adapter-node';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
out: 'build',
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
14
apps/skilltree/apps/web/tsconfig.json
Normal file
14
apps/skilltree/apps/web/tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
17
apps/skilltree/apps/web/vite.config.ts
Normal file
17
apps/skilltree/apps/web/vite.config.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
server: {
|
||||
port: 5195,
|
||||
strictPort: true,
|
||||
},
|
||||
ssr: {
|
||||
noExternal: ['@manacore/shared-tailwind', '@manacore/shared-theme'],
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ['@manacore/shared-tailwind', '@manacore/shared-theme'],
|
||||
},
|
||||
});
|
||||
14
apps/skilltree/package.json
Normal file
14
apps/skilltree/package.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"name": "skilltree",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "SkillTree - Gamified Personal Skill Tracking",
|
||||
"scripts": {
|
||||
"dev": "pnpm run --filter=@skilltree/* --parallel dev",
|
||||
"dev:web": "pnpm --filter @skilltree/web dev"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.0"
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue