update
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -45,6 +46,38 @@ async def get_topics():
|
|||||||
return db_topics + sorted(derived - set(db_topics))
|
return db_topics + sorted(derived - set(db_topics))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats")
|
||||||
|
async def get_stats():
|
||||||
|
"""Tracker: Themen-Anzahl + pro Format erstellt/absolviert (alle Kapitel abgehakt)."""
|
||||||
|
guides = await list_guides()
|
||||||
|
themen = set(await db_list_topics()) | {g["topic"] for g in guides} | set(bausteine_topics())
|
||||||
|
if PROJECTS_DIR.is_dir():
|
||||||
|
themen |= {e.name for e in PROJECTS_DIR.iterdir() if e.is_dir()}
|
||||||
|
|
||||||
|
formate = {}
|
||||||
|
for fmt in ("MiniGuide", "Guide", "FullGuide"):
|
||||||
|
# Pro Thema zählt nur der neueste fertige Guide (Altläufe teilen die Content-Datei)
|
||||||
|
neueste: dict[str, dict] = {}
|
||||||
|
for g in guides:
|
||||||
|
if g["format"] == fmt and g["status"] == "done":
|
||||||
|
if g["topic"] not in neueste or g["created_at"] > neueste[g["topic"]]["created_at"]:
|
||||||
|
neueste[g["topic"]] = g
|
||||||
|
absolviert = 0
|
||||||
|
for g in neueste.values():
|
||||||
|
path = guide_content_path(g["topic"], fmt)
|
||||||
|
if not path.exists():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
chapters = json.loads(path.read_text(encoding="utf-8")).get("chapters", [])
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
titles = {c.get("title") for c in chapters}
|
||||||
|
if titles and titles <= set(await list_progress(g["id"])):
|
||||||
|
absolviert += 1
|
||||||
|
formate[fmt] = {"erstellt": len(neueste), "absolviert": absolviert}
|
||||||
|
return {"themen": len(themen), "formate": formate}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/topics")
|
@router.post("/topics")
|
||||||
async def add_topic(req: TopicCreateRequest):
|
async def add_topic(req: TopicCreateRequest):
|
||||||
await create_topic(req.name.strip())
|
await create_topic(req.name.strip())
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import { fetchGuides, fetchTopics, createTopic as apiCreateTopic, deleteTopic as apiDeleteTopic, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, fetchBausteineStatus, fetchActiveBausteine, createBausteine as apiCreateBausteine, cancelBausteine as apiCancelBausteine, deleteBausteine as apiDeleteBausteine, fetchProjects, deleteProject as apiDeleteProject, fetchProviders } from './api.js'
|
import { fetchGuides, fetchTopics, createTopic as apiCreateTopic, deleteTopic as apiDeleteTopic, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, fetchBausteineStatus, fetchActiveBausteine, createBausteine as apiCreateBausteine, cancelBausteine as apiCancelBausteine, deleteBausteine as apiDeleteBausteine, fetchProjects, deleteProject as apiDeleteProject, fetchProviders, fetchStats } from './api.js'
|
||||||
import TopicSidebar from './components/TopicSidebar.vue'
|
import TopicSidebar from './components/TopicSidebar.vue'
|
||||||
import TopicDetail from './components/TopicDetail.vue'
|
import TopicDetail from './components/TopicDetail.vue'
|
||||||
|
|
||||||
@@ -21,6 +21,15 @@ const bausteine = ref({ ...EMPTY_BAUSTEINE })
|
|||||||
const activeBausteine = ref([])
|
const activeBausteine = ref([])
|
||||||
const provider = ref(localStorage.getItem('provider') || 'claude')
|
const provider = ref(localStorage.getItem('provider') || 'claude')
|
||||||
const providers = ref([])
|
const providers = ref([])
|
||||||
|
const stats = ref(null)
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
try {
|
||||||
|
stats.value = await fetchStats()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Fehler beim Laden der Statistik:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setProvider(id) {
|
function setProvider(id) {
|
||||||
provider.value = id
|
provider.value = id
|
||||||
@@ -112,6 +121,7 @@ async function loadTopics() {
|
|||||||
async function loadGuides() {
|
async function loadGuides() {
|
||||||
try {
|
try {
|
||||||
guides.value = await fetchGuides()
|
guides.value = await fetchGuides()
|
||||||
|
loadStats()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Fehler beim Laden:', e)
|
console.error('Fehler beim Laden:', e)
|
||||||
}
|
}
|
||||||
@@ -288,6 +298,7 @@ onUnmounted(() => {
|
|||||||
:topics="topics"
|
:topics="topics"
|
||||||
:projects="projectNames"
|
:projects="projectNames"
|
||||||
:selectedTopic="selectedTopic"
|
:selectedTopic="selectedTopic"
|
||||||
|
:stats="stats"
|
||||||
:doneByFormat="doneByFormat"
|
:doneByFormat="doneByFormat"
|
||||||
:latestByFormat="latestByFormat"
|
:latestByFormat="latestByFormat"
|
||||||
:allGuides="guides"
|
:allGuides="guides"
|
||||||
@@ -318,6 +329,7 @@ onUnmounted(() => {
|
|||||||
:previewGuide="previewGuide"
|
:previewGuide="previewGuide"
|
||||||
:dark="darkMode"
|
:dark="darkMode"
|
||||||
:provider="provider"
|
:provider="provider"
|
||||||
|
@progressChanged="loadStats"
|
||||||
/>
|
/>
|
||||||
<div v-else class="empty-main">
|
<div v-else class="empty-main">
|
||||||
<p>Thema in der Sidebar anlegen oder auswählen.</p>
|
<p>Thema in der Sidebar anlegen oder auswählen.</p>
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ export async function deleteBausteine(topic) {
|
|||||||
await fetch(`${BASE}/bausteine?topic=${encodeURIComponent(topic)}`, { method: 'DELETE' })
|
await fetch(`${BASE}/bausteine?topic=${encodeURIComponent(topic)}`, { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchStats() {
|
||||||
|
const res = await fetch(`${BASE}/stats`)
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchProviders() {
|
export async function fetchProviders() {
|
||||||
const res = await fetch(`${BASE}/providers`)
|
const res = await fetch(`${BASE}/providers`)
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ const props = defineProps({
|
|||||||
provider: { type: String, default: 'claude' },
|
provider: { type: String, default: 'claude' },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['progressChanged'])
|
||||||
|
|
||||||
const isOnePager = computed(() => props.previewGuide?.format === 'OnePager')
|
const isOnePager = computed(() => props.previewGuide?.format === 'OnePager')
|
||||||
|
|
||||||
// --- Inhalt laden ---
|
// --- Inhalt laden ---
|
||||||
@@ -73,6 +75,7 @@ async function toggleChapter(title) {
|
|||||||
try {
|
try {
|
||||||
const res = await setProgress(props.previewGuide.id, title, newState)
|
const res = await setProgress(props.previewGuide.id, title, newState)
|
||||||
doneChapters.value = new Set(res.chapters || [])
|
doneChapters.value = new Set(res.chapters || [])
|
||||||
|
emit('progressChanged')
|
||||||
} catch {
|
} catch {
|
||||||
const rollback = new Set(doneChapters.value)
|
const rollback = new Set(doneChapters.value)
|
||||||
if (newState) rollback.delete(title)
|
if (newState) rollback.delete(title)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const props = defineProps({
|
|||||||
topics: { type: Array, required: true },
|
topics: { type: Array, required: true },
|
||||||
projects: { type: Array, default: () => [] },
|
projects: { type: Array, default: () => [] },
|
||||||
selectedTopic: { type: String, default: null },
|
selectedTopic: { type: String, default: null },
|
||||||
|
stats: { type: Object, default: null },
|
||||||
doneByFormat: { type: Object, default: () => ({}) },
|
doneByFormat: { type: Object, default: () => ({}) },
|
||||||
latestByFormat: { type: Object, default: () => ({}) },
|
latestByFormat: { type: Object, default: () => ({}) },
|
||||||
allGuides: { type: Array, default: () => [] },
|
allGuides: { type: Array, default: () => [] },
|
||||||
@@ -25,6 +26,19 @@ function providerAvailable(id) {
|
|||||||
|
|
||||||
const PROVIDER_LABELS = { claude: 'Claude', minimax: 'MiniMax', lokal: 'Lokal' }
|
const PROVIDER_LABELS = { claude: 'Claude', minimax: 'MiniMax', lokal: 'Lokal' }
|
||||||
|
|
||||||
|
// Tracker oben in der Navigation: Themen gesamt, pro Format erstellt/absolviert
|
||||||
|
const trackerItems = computed(() => {
|
||||||
|
if (!props.stats) return []
|
||||||
|
const f = props.stats.formate || {}
|
||||||
|
const fmt = (k) => `${f[k]?.absolviert ?? 0}/${f[k]?.erstellt ?? 0}`
|
||||||
|
return [
|
||||||
|
{ label: 'Themen', value: String(props.stats.themen ?? 0), title: 'Themen inkl. Projekte' },
|
||||||
|
{ label: 'MiniGuides', value: fmt('MiniGuide'), title: 'absolviert/erstellt' },
|
||||||
|
{ label: 'Guides', value: fmt('Guide'), title: 'absolviert/erstellt' },
|
||||||
|
{ label: 'FullGuides', value: fmt('FullGuide'), title: 'absolviert/erstellt' },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
const formats = [
|
const formats = [
|
||||||
{ key: 'OnePager', label: 'OnePager' },
|
{ key: 'OnePager', label: 'OnePager' },
|
||||||
{ key: 'MiniGuide', label: 'MiniGuide' },
|
{ key: 'MiniGuide', label: 'MiniGuide' },
|
||||||
@@ -167,6 +181,12 @@ function confirmDeleteProject(name) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<aside class="sidebar" @mouseleave="emit('sidebarLeave')">
|
<aside class="sidebar" @mouseleave="emit('sidebarLeave')">
|
||||||
|
<div class="stats-bar" v-if="trackerItems.length">
|
||||||
|
<div class="stat" v-for="item in trackerItems" :key="item.label" :title="item.title">
|
||||||
|
<span class="stat-value">{{ item.value }}</span>
|
||||||
|
<span class="stat-label">{{ item.label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="new-topic">
|
<div class="new-topic">
|
||||||
<button
|
<button
|
||||||
class="pin-btn"
|
class="pin-btn"
|
||||||
@@ -407,6 +427,40 @@ function confirmDeleteProject(name) {
|
|||||||
border-color: var(--accent-border);
|
border-color: var(--accent-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stats-bar {
|
||||||
|
display: flex;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
gap: 4px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1px;
|
||||||
|
padding: 4px 2px;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.58rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: var(--text-faint);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.provider-toggle {
|
.provider-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user