ui feautres

This commit is contained in:
beo3000 2026-02-20 13:28:36 +01:00
parent 867cffb6a1
commit 1acdc0b4cd
16 changed files with 776 additions and 14 deletions

199
help.md Normal file
View File

@ -0,0 +1,199 @@
# Ka-Note — Entitäten & Datenmodell
## Hierarchie
```
Kontext (AgendaContext) z.B. "JF Team Sysadmins", "Project TISAX"
└─ Thema (Topic) z.B. "TISAX: Sperren Produktionsrechner"
└─ Eintrag (HistoryEntry) z.B. "Hr. Müller angerufen. Server down."
└─ Bewertung (Rating) z.B. STEFE gibt 3/4
```
---
## Entitäten im Detail
### 1. Kontext (AgendaContext)
Oberster Container. Gruppiert Themen nach Anlass.
| Feld | Beschreibung |
|------|-------------|
| `id` | UUID (Sonderfall: `"daily-log"` ist hartkodiert) |
| `name` | Anzeigename |
| `type` | `meeting` · `project` · `person` |
| `sortOrder` | Reihenfolge in der Sidebar |
| `meta` | Typ-abhängig: ProjectMeta oder PersonMeta |
| `archivedAt` | Archiviert (soft) |
**Drei Typen:**
| Typ | Zweck | Meta-Felder |
|-----|-------|-------------|
| `meeting` | Regelmeetings, Daily Log | — |
| `project` | Projektbezogene Themen | status, owner, links |
| `person` | Personenbezogene Themen | fullName, email, phone, duSince |
**Sonder-Kontext:** `daily-log` — immer vorhanden, Standard-Inbox, immer Meeting-Modus.
---
### 2. Thema (Topic)
Ein Diskussionspunkt / Agenda-Item / Aufgabenblock innerhalb eines Kontexts.
**Bündelt mehrere Einträge (Notizen).**
| Feld | Beschreibung |
|------|-------------|
| `id` | UUID |
| `contextId` | Verweis auf übergeordneten Kontext |
| `title` | Titel des Themas |
| `status` | `active` · `snoozed` · `done` |
| `snoozeUntil` | Datum, ab dem snoozed-Thema wieder erscheint |
| `sortOrder` | Reihenfolge innerhalb des Kontexts |
| `isNew` | Zeigt grünes "NEU"-Badge bis erster Eintrag erstellt |
**Status-Bedeutung:**
| Status | Bedeutung |
|--------|-----------|
| `active` | Offen, wird angezeigt |
| `snoozed` | Versteckt bis `snoozeUntil`-Datum |
| `done` | Erledigt / archiviert |
**Sonder-Themen (automatisch, versteckt):**
- `daily-log-journal` — Journal-Einträge im Daily Log
- `{contextId}-notes` — Freie Notizen je Projekt/Person
---
### 3. Eintrag (HistoryEntry)
Eine einzelne, datierte Notiz innerhalb eines Themas. Markdown-formatiert.
| Feld | Beschreibung |
|------|-------------|
| `id` | UUID |
| `topicId` | Verweis auf übergeordnetes Thema |
| `date` | Datum (YYYY-MM-DD) |
| `text` | Markdown-Inhalt mit Inline-Tags |
| `sortOrder` | Reihenfolge (neueste oben) |
| `linkedContextId` | Optionaler Verweis auf anderen Kontext |
| `doneAt` | Erledigt-Zeitstempel (toggle) |
**Inline-Tags im Text:**
| Syntax | Zweck | Beispiel |
|--------|-------|---------|
| `-> NAME` | Zuweisung an Person | `-> STEFE` |
| `@P:PROJEKT` | Projekt-Referenz | `@P:TISAX` |
| `@NAME` | Person erwähnen | `@CHFI` |
---
### 4. Bewertung (Rating)
Bewertung eines Eintrags durch eine Person (14 Skala).
| Feld | Beschreibung |
|------|-------------|
| `id` | UUID |
| `topicId` | Verweis auf Thema |
| `historyEntryId` | Verweis auf bewerteten Eintrag |
| `personName` | Name des Bewertenden |
| `value` | `1` · `2` · `3` · `4` |
---
## Zusammenfassung: "Thema anlegen" vs. "Eintrag hinzufügen"
| Aktion | Erzeugt | Was passiert |
|--------|---------|-------------|
| **Thema anlegen** | Topic | Neuer Diskussionspunkt/Block. Kann danach beliebig viele Einträge enthalten. Erscheint mit "NEU"-Badge. |
| **Eintrag hinzufügen** | HistoryEntry | Einzelne datierte Notiz innerhalb eines bestehenden Themas. Markdown mit Inline-Tags. |
**Kurz:** Ein Thema ist der Ordner, ein Eintrag ist das Blatt darin.
---
## Querschnittliche Konzepte
### Soft-Delete
Nichts wird hart gelöscht. Stattdessen wird `deletedAt` gesetzt. Ermöglicht Wiederherstellung und Sync.
### Basis-Felder (SyncEntity)
Jede Entität hat: `id`, `updatedAt`, `deletedAt`, `version`.
### Session-State (nicht persistiert)
- `processedInCurrentSession` — Thema wurde in laufender Sitzung besprochen (sessionStorage, verschwindet bei Tab-Schließung)
- `isCollapsed` — UI-only, ob Thema-Karte eingeklappt ist (nur im Svelte-Store)
### Modi
- **Prep-Modus** — Vorbereitung: Themen anlegen, Einträge schreiben
- **Meeting-Modus** — Durchführung: Themen als besprochen markieren, aufteilen in "Aktuell" / "Bereits besprochen"
---
## Datenspeicherung
### Client: IndexedDB via Dexie.js
Alle Daten liegen **lokal im Browser** in einer IndexedDB-Datenbank.
| Eigenschaft | Wert |
|-------------|------|
| DB-Name | `ka-note` |
| Library | Dexie.js v4 |
| Schema-Version | 5 |
| Schema-Datei | `client/src/lib/db/schema.ts` |
**Tabellen & Indizes:**
| Tabelle | Indizes | Zweck |
|---------|---------|-------|
| `contexts` | `id, type, sortOrder, deletedAt, archivedAt` | Kontexte |
| `topics` | `id, contextId, status, sortOrder, deletedAt` | Themen |
| `historyEntries` | `id, topicId, date, sortOrder, deletedAt, linkedContextId, doneAt` | Einträge |
| `ratings` | `id, topicId, historyEntryId, personName, deletedAt` | Bewertungen |
| `syncMeta` | `id, entityType, entityId, synced` | Sync-Status (0=unsynced, 1=synced) |
| `imageBlobs` | `id, createdAt` | Bilder als Binärdaten in IndexedDB |
**Reaktive Abfragen:** Svelte-Stores nutzen Dexie `liveQuery` (`client/src/lib/stores/agenda.ts`) — UI aktualisiert sich automatisch bei DB-Änderungen.
**CRUD-Schicht:** `client/src/lib/db/repositories.ts` — alle Lese-/Schreiboperationen.
**Seed-Daten:** `client/src/lib/db/seed.ts` — Beispieldaten beim ersten Start.
### Server: SQLite via Hono
| Eigenschaft | Wert |
|-------------|------|
| Framework | Hono v4 + @hono/node-server |
| Datenbank | SQLite |
| DB-Dateipfad (Docker) | `/data/ka-note.db` |
| Env-Variable | `DATABASE_PATH` |
| Port | 3001 |
**Aktueller Stand:** Der Server stellt bisher nur einen Health-Endpoint bereit (`GET /api/health`). Drizzle ORM und Sync-Endpunkte sind noch nicht implementiert.
**Docker-Volume:** Die SQLite-Datei wird über ein Docker-Volume (`server-data`) persistiert, sodass Daten Container-Neustarts überleben.
### Sync-Mechanismus (in Entwicklung)
Infrastruktur vorbereitet, aber noch nicht aktiv:
- **SyncMeta-Tabelle** im Client trackt pro Entität, ob sie synchronisiert ist (`synced: 0|1`)
- **`version`-Feld** auf jeder Entität für Konflikterkennung
- **`updatedAt`-Timestamp** (ISO 8601) für Reihenfolge
- **Soft-Delete** ermöglicht sichere Sync-Abgleichung (gelöschte Einträge bleiben als Marker erhalten)
### Session-Speicher (flüchtig)
| Speicher | Key | Inhalt | Lebensdauer |
|----------|-----|--------|-------------|
| sessionStorage | `ka-note-processed` | IDs besprochener Themen | Bis Tab geschlossen |
| Svelte-Store (Memory) | — | Collapsed-Status der Themen-Karten | Bis Seite neu geladen |
### Bilder
Bilder werden als Blobs direkt in IndexedDB gespeichert (Tabelle `imageBlobs`). Im Markdown-Text werden sie über `ka-img:{imageId}` referenziert. Verwaltung in `client/src/lib/db/imageStore.ts`.

View File

@ -104,6 +104,12 @@ ul.tree-list li {
padding-left: 20px;
}
/* Rating indicators on @NAME tags */
.person-ref.rating-1 { border-bottom: 3px solid #d9534f !important; }
.person-ref.rating-2 { border-bottom: 3px solid #f0ad4e !important; }
.person-ref.rating-3 { border-bottom: 3px solid #5cb85c !important; }
.person-ref.rating-4 { border-bottom: 3px solid #5bc0de !important; }
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;

View File

@ -0,0 +1,82 @@
import { liveQuery } from 'dexie';
import { db } from '$lib/db/schema';
import type { Rating } from '@ka-note/shared';
export function ratingIndicator(node: HTMLElement) {
let subscription: { unsubscribe(): void } | null = null;
function findContext(el: HTMLElement): { topicId: string; historyEntryId: string } | null {
const wrapper = el.closest<HTMLElement>('[data-topic-id][data-history-entry-id]');
if (!wrapper) return null;
const topicId = wrapper.dataset.topicId;
const historyEntryId = wrapper.dataset.historyEntryId;
if (!topicId || !historyEntryId) return null;
return { topicId, historyEntryId };
}
function applyIndicators() {
// Clean up previous subscription
subscription?.unsubscribe();
const personEls = node.querySelectorAll<HTMLElement>('.person-ref[data-person]');
if (personEls.length === 0) return;
// Collect unique (topicId, historyEntryId) pairs
const lookups: { el: HTMLElement; person: string; topicId: string; historyEntryId: string }[] = [];
const entryIds = new Set<string>();
for (const el of personEls) {
const person = el.dataset.person;
if (!person) continue;
const ctx = findContext(el);
if (!ctx) continue;
lookups.push({ el, person, ...ctx });
entryIds.add(ctx.historyEntryId);
}
if (lookups.length === 0) return;
const entryIdArray = [...entryIds];
const observable = liveQuery(async () => {
const ratings = await db.ratings
.where('historyEntryId').anyOf(entryIdArray)
.filter(r => !r.deletedAt)
.toArray();
return ratings;
});
subscription = observable.subscribe({
next(ratings: Rating[]) {
// Remove old classes
for (const l of lookups) {
l.el.classList.remove('rating-1', 'rating-2', 'rating-3', 'rating-4');
}
// Apply new classes
for (const l of lookups) {
const r = ratings.find(
r => r.historyEntryId === l.historyEntryId && r.topicId === l.topicId && r.personName === l.person
);
if (r) {
l.el.classList.add(`rating-${r.value}`);
}
}
},
error() {}
});
}
// Observe for DOM changes (from {@html} rendering)
const observer = new MutationObserver(() => applyIndicators());
observer.observe(node, { childList: true, subtree: true });
// Initial apply
applyIndicators();
return {
destroy() {
observer.disconnect();
subscription?.unsubscribe();
}
};
}

View File

@ -16,6 +16,77 @@ function closePopup() {
}
}
function findRatingContext(el: HTMLElement): { topicId: string; historyEntryId: string } | null {
const wrapper = el.closest<HTMLElement>('[data-topic-id][data-history-entry-id]');
if (!wrapper) return null;
const topicId = wrapper.dataset.topicId;
const historyEntryId = wrapper.dataset.historyEntryId;
if (!topicId || !historyEntryId) return null;
return { topicId, historyEntryId };
}
function showPersonPopup(name: string, contextId: string, ratingCtx: { topicId: string; historyEntryId: string } | null, x: number, y: number, sourceEl: HTMLElement) {
closePopup();
const popup = document.createElement('div');
popup.className = 'ref-create-popup';
popup.style.cssText = `
position: fixed; left: ${x}px; top: ${y}px; z-index: 100;
background: #2d2d2d; border: 1px solid #555; border-radius: 6px;
padding: 8px 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.5);
font-size: 0.85rem; color: #e0e0e0;
`;
const btnRow = document.createElement('div');
btnRow.style.cssText = 'display: flex; gap: 6px;';
if (ratingCtx) {
const rateBtn = document.createElement('button');
rateBtn.textContent = 'Bewerten';
rateBtn.style.cssText = `
padding: 3px 10px; border-radius: 4px; border: 1px solid #666;
background: #444; color: #fff; cursor: pointer; font-size: 0.8rem;
`;
rateBtn.addEventListener('click', () => {
closePopup();
sourceEl.dispatchEvent(new CustomEvent('ka-open-rating', {
bubbles: true,
detail: {
personName: name,
topicId: ratingCtx.topicId,
historyEntryId: ratingCtx.historyEntryId
}
}));
});
btnRow.appendChild(rateBtn);
}
const gotoBtn = document.createElement('button');
gotoBtn.textContent = 'Zur Person';
gotoBtn.style.cssText = `
padding: 3px 10px; border-radius: 4px; border: 1px solid #666;
background: #444; color: #fff; cursor: pointer; font-size: 0.8rem;
`;
gotoBtn.addEventListener('click', () => {
closePopup();
goto(`/context/${contextId}`);
});
btnRow.appendChild(gotoBtn);
popup.appendChild(btnRow);
document.body.appendChild(popup);
const onClickOutside = (e: MouseEvent) => {
if (!popup.contains(e.target as Node)) closePopup();
};
setTimeout(() => document.addEventListener('click', onClickOutside), 0);
activePopup = {
el: popup,
cleanup: () => document.removeEventListener('click', onClickOutside)
};
}
function showCreatePopup(name: string, type: 'person' | 'project', x: number, y: number) {
closePopup();
@ -86,12 +157,22 @@ function showCreatePopup(name: string, type: 'person' | 'project', x: number, y:
};
}
async function handleRefClick(name: string, type: 'person' | 'project', event: MouseEvent) {
const ctx = await findContextByMentionName(name, type);
async function handlePersonClick(name: string, event: MouseEvent, sourceEl: HTMLElement) {
const ctx = await findContextByMentionName(name, 'person');
if (ctx) {
const ratingCtx = findRatingContext(sourceEl);
showPersonPopup(name, ctx.id, ratingCtx, event.clientX, event.clientY, sourceEl);
} else {
showCreatePopup(name, 'person', event.clientX, event.clientY);
}
}
async function handleProjectClick(name: string, event: MouseEvent) {
const ctx = await findContextByMentionName(name, 'project');
if (ctx) {
goto(`/context/${ctx.id}`);
} else {
showCreatePopup(name, type, event.clientX, event.clientY);
showCreatePopup(name, 'project', event.clientX, event.clientY);
}
}
@ -103,7 +184,7 @@ export function refClick(node: HTMLElement) {
if (personEl) {
e.preventDefault();
e.stopPropagation();
handleRefClick(personEl.dataset.person!, 'person', e);
handlePersonClick(personEl.dataset.person!, e, personEl);
return;
}
@ -111,7 +192,7 @@ export function refClick(node: HTMLElement) {
if (projectEl) {
e.preventDefault();
e.stopPropagation();
handleRefClick(projectEl.dataset.project!, 'project', e);
handleProjectClick(projectEl.dataset.project!, e);
return;
}
}

View File

@ -9,6 +9,8 @@
import PersonsView from './PersonsView.svelte';
import SnoozedView from './SnoozedView.svelte';
import DashboardView from './DashboardView.svelte';
import RatingModal from './RatingModal.svelte';
import RatingsView from './RatingsView.svelte';
interface Props {
contextId: string;
@ -21,6 +23,9 @@
let activeView = $state('agenda');
let compact = $state(false);
// Rating modal state
let ratingModal = $state<{ personName: string; topicId: string; historyEntryId: string } | null>(null);
// Default view based on context type
$effect(() => {
if ($context) {
@ -56,8 +61,24 @@
collapsedTopicIds.set(new Set());
}
}
let containerEl = $state<HTMLDivElement>();
function handleRatingEvent(e: Event) {
const detail = (e as CustomEvent).detail;
if (detail?.personName && detail?.topicId && detail?.historyEntryId) {
ratingModal = detail;
}
}
$effect(() => {
if (!containerEl) return;
containerEl.addEventListener('ka-open-rating', handleRatingEvent);
return () => containerEl?.removeEventListener('ka-open-rating', handleRatingEvent);
});
</script>
<div bind:this={containerEl}>
{#if $context}
<ContextHeader context={$context} {mode} onmodechange={handleModeChange} />
<ViewTabs context={$context} {activeView} {compact} onviewchange={handleViewChange} ontogglecompact={toggleCompact} />
@ -72,7 +93,19 @@
<SnoozedView {contextId} />
{:else if activeView === 'dashboard'}
<DashboardView context={$context} />
{:else if activeView === 'ratings'}
<RatingsView personName={$context.name.replace(/^Person /, '')} />
{/if}
{:else}
<div class="text-muted">Context not found.</div>
{/if}
</div>
{#if ratingModal}
<RatingModal
personName={ratingModal.personName}
topicId={ratingModal.topicId}
historyEntryId={ratingModal.historyEntryId}
onclose={() => ratingModal = null}
/>
{/if}

View File

@ -147,7 +147,7 @@
const allHistory = await db.historyEntries.filter(h => !h.deletedAt).toArray();
const allContexts = await db.contexts.filter(c => !c.deletedAt).toArray();
const results: { topicTitle: string; contextName: string; date: string; text: string }[] = [];
const results: { topicId: string; historyEntryId: string; topicTitle: string; contextName: string; date: string; text: string }[] = [];
for (const h of allHistory) {
const topic = allTopics.find(t => t.id === h.topicId);
@ -164,6 +164,8 @@
if (matches) {
results.push({
topicId: topic.id,
historyEntryId: h.id,
topicTitle: topic.title,
contextName: ctx?.name ?? 'Unknown',
date: h.date,
@ -405,7 +407,7 @@
</div>
{#each $activityLog ?? [] as entry}
<div class="mb-2.5 border-l-2 border-[#444] pl-2.5">
<div class="mb-2.5 border-l-2 border-[#444] pl-2.5" data-topic-id={entry.topicId} data-history-entry-id={entry.historyEntryId}>
<div class="mb-0.5 text-xs text-[#aaa]">
{entry.date} in <span class="text-accent">{entry.contextName}</span>: {entry.topicTitle}
</div>

View File

@ -27,7 +27,7 @@
}
</script>
<div class="mb-4 border-b border-[#333] pb-2.5 last:border-b-0 group relative hover:bg-[#2a2a2a] rounded">
<div class="mb-4 border-b border-[#333] pb-2.5 last:border-b-0 group relative hover:bg-[#2a2a2a] rounded" data-history-entry-id={entry.id} data-topic-id={entry.topicId}>
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-bold text-accent">[{entry.date}]</span>
{#if !editing}

View File

@ -0,0 +1,105 @@
<script lang="ts">
import { RATING_CONFIG, RATING_LEVELS, type RatingValue } from '$lib/utils/ratingConfig';
import { getRating, upsertRating } from '$lib/db/repositories';
import type { Rating } from '@ka-note/shared';
interface Props {
personName: string;
topicId: string;
historyEntryId: string;
onclose: () => void;
}
let { personName, topicId, historyEntryId, onclose }: Props = $props();
let selectedValue = $state<RatingValue | null>(null);
let comment = $state('');
let loading = $state(true);
let existingRating = $state<Rating | null>(null);
$effect(() => {
loadExisting();
});
async function loadExisting() {
loading = true;
const r = await getRating(topicId, historyEntryId, personName);
if (r) {
existingRating = r;
selectedValue = r.value;
comment = r.comment ?? '';
}
loading = false;
}
async function save() {
if (!selectedValue) return;
const trimmed = comment.trim() || null;
await upsertRating(topicId, historyEntryId, personName, selectedValue, trimmed);
onclose();
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') onclose();
}
</script>
<svelte:window onkeydown={onKeydown} />
<!-- Backdrop -->
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
onclick={(e) => { if (e.target === e.currentTarget) onclose(); }}
>
<div class="w-full max-w-md rounded-lg border border-[#555] bg-[#2d2d2d] p-6 shadow-2xl" onclick={(e) => e.stopPropagation()}>
<h3 class="m-0 mb-4 text-lg font-bold text-white">
Bewertung: <span class="text-person-ref">@{personName}</span>
</h3>
{#if loading}
<div class="py-4 text-center text-muted">Loading...</div>
{:else}
<!-- Rating buttons -->
<div class="mb-4 grid grid-cols-4 gap-2">
{#each RATING_LEVELS as val}
{@const cfg = RATING_CONFIG[val]}
<button
class="flex flex-col items-center gap-1 rounded-lg border-2 px-2 py-3 text-center transition-all {selectedValue === val ? cfg.borderClass + ' bg-opacity-20' : 'border-[#444] hover:border-[#666]'}"
style={selectedValue === val ? `background-color: ${cfg.color}22;` : ''}
onclick={() => selectedValue = val}
>
<span
class="flex h-8 w-8 items-center justify-center rounded-full text-sm font-bold text-white"
style="background-color: {cfg.color};"
>{val}</span>
<span class="text-xs text-[#ccc]">{cfg.label}</span>
</button>
{/each}
</div>
<!-- Comment -->
<div class="mb-4">
<label class="mb-1 block text-sm text-[#aaa]">Kommentar (optional)</label>
<textarea
class="w-full rounded border border-[#444] bg-[#111] px-3 py-2 text-sm text-white placeholder-[#666] focus:border-accent focus:outline-none"
rows="3"
placeholder="Anmerkung zur Bewertung..."
bind:value={comment}
></textarea>
</div>
<!-- Actions -->
<div class="flex justify-end gap-2">
<button
class="rounded bg-[#444] px-4 py-2 text-sm text-[#ccc] hover:bg-[#555]"
onclick={onclose}
>Abbrechen</button>
<button
class="rounded px-4 py-2 text-sm font-bold text-white disabled:opacity-40 {selectedValue ? 'bg-accent hover:brightness-110' : 'bg-[#555]'}"
disabled={!selectedValue}
onclick={save}
>{existingRating ? 'Aktualisieren' : 'Speichern'}</button>
</div>
{/if}
</div>
</div>

View File

@ -0,0 +1,163 @@
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/db/schema';
import { softDeleteRating } from '$lib/db/repositories';
import { RATING_CONFIG, type RatingValue } from '$lib/utils/ratingConfig';
import { goto } from '$app/navigation';
import type { Rating } from '@ka-note/shared';
interface Props {
personName: string;
}
let { personName }: Props = $props();
interface EnrichedRating {
rating: Rating;
date: string;
entryPreview: string;
topicTitle: string;
contextName: string;
contextId: string;
}
const enrichedRatings = liveQuery(async () => {
const ratings = await db.ratings
.where('personName').equals(personName)
.filter(r => !r.deletedAt)
.toArray();
const results: EnrichedRating[] = [];
for (const r of ratings) {
const entry = await db.historyEntries.get(r.historyEntryId);
if (!entry || entry.deletedAt) continue;
const topic = await db.topics.get(r.topicId);
if (!topic) continue;
const ctx = await db.contexts.get(topic.contextId);
results.push({
rating: r,
date: entry.date,
entryPreview: entry.text.split('\n')[0].slice(0, 80),
topicTitle: topic.title,
contextName: ctx?.name ?? 'Unknown',
contextId: topic.contextId
});
}
return results.sort((a, b) => b.date.localeCompare(a.date));
});
// Group by year → month
interface MonthGroup { month: string; label: string; items: EnrichedRating[]; }
interface YearGroup { year: string; months: MonthGroup[]; }
const MONTH_NAMES = ['', 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
const grouped = $derived.by<YearGroup[]>(() => {
const items = $enrichedRatings ?? [];
const yearMap = new Map<string, Map<string, EnrichedRating[]>>();
for (const item of items) {
const [y, m] = item.date.split('-');
if (!yearMap.has(y)) yearMap.set(y, new Map());
const monthMap = yearMap.get(y)!;
if (!monthMap.has(m)) monthMap.set(m, []);
monthMap.get(m)!.push(item);
}
const result: YearGroup[] = [];
for (const [year, monthMap] of [...yearMap.entries()].sort((a, b) => b[0].localeCompare(a[0]))) {
const months: MonthGroup[] = [];
for (const [month, items] of [...monthMap.entries()].sort((a, b) => b[0].localeCompare(a[0]))) {
months.push({ month, label: MONTH_NAMES[parseInt(month)] ?? month, items });
}
result.push({ year, months });
}
return result;
});
const summary = $derived.by(() => {
const items = $enrichedRatings ?? [];
if (items.length === 0) return null;
const total = items.length;
const avg = items.reduce((s, i) => s + i.rating.value, 0) / total;
const dist = [0, 0, 0, 0];
for (const i of items) dist[i.rating.value - 1]++;
return { total, avg: avg.toFixed(1), dist };
});
async function handleDelete(id: string) {
await softDeleteRating(id);
}
</script>
<!-- Summary card -->
{#if summary}
<div class="mb-5 rounded-lg border border-[#444] bg-sidebar p-4">
<div class="mb-3 flex items-center justify-between">
<span class="text-sm font-bold text-info">Bewertungen Zusammenfassung</span>
<span class="text-sm text-[#aaa]">{summary.total} Bewertung{summary.total !== 1 ? 'en' : ''} — Durchschnitt: {summary.avg}</span>
</div>
<div class="flex gap-1">
{#each [1, 2, 3, 4] as val}
{@const cfg = RATING_CONFIG[val as RatingValue]}
{@const count = summary.dist[val - 1]}
{@const pct = summary.total > 0 ? (count / summary.total) * 100 : 0}
<div class="flex-1 overflow-hidden rounded" title="{cfg.label}: {count}x">
<div class="h-6 transition-all" style="width: {pct}%; background-color: {cfg.color}; min-width: {count > 0 ? '8px' : '0'};">
</div>
<div class="mt-1 text-center text-xs text-[#888]">{val}: {count}</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- Grouped ratings -->
{#if grouped.length === 0}
<div class="text-muted">Keine Bewertungen vorhanden.</div>
{:else}
{#each grouped as yg (yg.year)}
<div class="mb-6">
<h3 class="mb-3 text-lg font-bold text-[#ccc]">{yg.year}</h3>
{#each yg.months as mg (mg.month)}
<div class="mb-4 ml-2">
<h4 class="mb-2 text-sm font-bold text-[#999]">{mg.label}</h4>
{#each mg.items as item (item.rating.id)}
{@const cfg = RATING_CONFIG[item.rating.value]}
<div class="group mb-2 flex items-start gap-3 rounded border border-[#333] bg-card-bg p-3 hover:border-[#555]">
<span
class="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white"
style="background-color: {cfg.color};"
>{item.rating.value}</span>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 text-xs text-[#aaa]">
<span>{item.date}</span>
<span class="text-[#666]">|</span>
<span class="text-accent">{item.contextName}</span>
<span class="text-[#666]">|</span>
<span>{item.topicTitle}</span>
</div>
<div class="mt-1 text-sm text-[#ccc]">{cfg.label}</div>
{#if item.rating.comment}
<div class="mt-1 text-sm italic text-[#999]">{item.rating.comment}</div>
{/if}
<div class="mt-1 truncate text-xs text-[#666]">{item.entryPreview}</div>
</div>
<div class="flex shrink-0 items-center gap-2">
<button
class="text-xs text-[#666] hover:text-accent"
onclick={() => goto(`/context/${item.contextId}`)}
>Zum Kontext &rarr;</button>
<button
class="text-xs text-[#666] opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-400"
onclick={() => handleDelete(item.rating.id)}
title="Bewertung löschen"
>&times;</button>
</div>
</div>
{/each}
</div>
{/each}
</div>
{/each}
{/if}

View File

@ -2,6 +2,7 @@
import { renderMarkdown } from '$lib/utils/renderMarkdown';
import { resolveImageUrls } from '$lib/db/imageStore';
import { refClick } from '$lib/actions/refClick';
import { ratingIndicator } from '$lib/actions/ratingIndicator';
interface Props {
text: string;
@ -22,4 +23,4 @@
});
</script>
<div class="markdown-content {className}" use:refClick>{@html html}</div>
<div class="markdown-content {className}" use:refClick use:ratingIndicator>{@html html}</div>

View File

@ -16,6 +16,8 @@
const isDailyLog = $derived(context.id === 'daily-log');
const isPerson = $derived(context.type === 'person');
const tabs = $derived<Tab[]>(
isMeeting
? [
@ -24,7 +26,10 @@
{ id: 'persons', label: 'Personen (All)' },
{ id: 'snoozed', label: 'Verschoben' }
]
: [{ id: 'dashboard', label: `Dashboard: ${context.name.replace(/^(Project |Person )/, '')}` }]
: [
{ id: 'dashboard', label: `Dashboard: ${context.name.replace(/^(Project |Person )/, '')}` },
...(isPerson ? [{ id: 'ratings', label: 'Bewertungen' }] : [])
]
);
</script>

View File

@ -240,8 +240,8 @@ export async function toggleHistoryEntryDone(id: string): Promise<void> {
export async function getRating(topicId: string, historyEntryId: string, personName: string): Promise<Rating | undefined> {
return db.ratings
.where('[topicId+historyEntryId+personName]')
.equals([topicId, historyEntryId, personName])
.where('historyEntryId').equals(historyEntryId)
.filter(r => r.topicId === topicId && r.personName === personName && !r.deletedAt)
.first();
}
@ -252,13 +252,13 @@ export async function getRatingsByHistoryEntry(historyEntryId: string): Promise<
.toArray();
}
export async function upsertRating(topicId: string, historyEntryId: string, personName: string, value: 1 | 2 | 3 | 4): Promise<void> {
export async function upsertRating(topicId: string, historyEntryId: string, personName: string, value: 1 | 2 | 3 | 4, comment: string | null = null): Promise<void> {
const existing = await db.ratings
.where('historyEntryId').equals(historyEntryId)
.filter(r => r.topicId === topicId && r.personName === personName && !r.deletedAt)
.first();
if (existing) {
await db.ratings.update(existing.id, { value, updatedAt: now(), version: existing.version + 1 });
await db.ratings.update(existing.id, { value, comment, updatedAt: now(), version: existing.version + 1 });
} else {
await db.ratings.put({
id: newId(),
@ -266,9 +266,24 @@ export async function upsertRating(topicId: string, historyEntryId: string, pers
historyEntryId,
personName,
value,
comment,
updatedAt: now(),
deletedAt: null,
version: 1
});
}
}
export async function getRatingsByPerson(personName: string): Promise<Rating[]> {
return db.ratings
.where('personName').equals(personName)
.filter(r => !r.deletedAt)
.toArray();
}
export async function softDeleteRating(id: string): Promise<void> {
const rating = await db.ratings.get(id);
if (rating) {
await db.ratings.update(id, { deletedAt: now(), updatedAt: now(), version: rating.version + 1 });
}
}

View File

@ -54,6 +54,12 @@ export class KaNoteDB extends Dexie {
})
]);
});
this.version(5).stores({}).upgrade(tx => {
return tx.table('ratings').toCollection().modify(rating => {
if (rating.comment === undefined) rating.comment = null;
});
});
}
}

View File

@ -109,3 +109,12 @@ export function ratingsForHistoryEntry(historyEntryId: string) {
.toArray()
);
}
export function ratingsForPerson(personName: string) {
return liveQuery(() =>
db.ratings
.where('personName').equals(personName)
.filter(r => !r.deletedAt)
.toArray()
);
}

View File

@ -0,0 +1,54 @@
export const RATING_LEVELS = [1, 2, 3, 4] as const;
export type RatingValue = (typeof RATING_LEVELS)[number];
export interface RatingLevel {
value: RatingValue;
label: string;
shortLabel: string;
color: string;
bgClass: string;
borderClass: string;
}
export const RATING_CONFIG: Record<RatingValue, RatingLevel> = {
1: {
value: 1,
label: 'Unter Erwartung',
shortLabel: '1',
color: '#d9534f',
bgClass: 'bg-[#d9534f]',
borderClass: 'border-[#d9534f]'
},
2: {
value: 2,
label: 'Teilweise erfüllt',
shortLabel: '2',
color: '#f0ad4e',
bgClass: 'bg-[#f0ad4e]',
borderClass: 'border-[#f0ad4e]'
},
3: {
value: 3,
label: 'Erfüllt',
shortLabel: '3',
color: '#5cb85c',
bgClass: 'bg-[#5cb85c]',
borderClass: 'border-[#5cb85c]'
},
4: {
value: 4,
label: 'Übertrifft',
shortLabel: '4',
color: '#5bc0de',
bgClass: 'bg-[#5bc0de]',
borderClass: 'border-[#5bc0de]'
}
};
export function getRatingColor(value: RatingValue): string {
return RATING_CONFIG[value].color;
}
export function getRatingLabel(value: RatingValue): string {
return RATING_CONFIG[value].label;
}

View File

@ -53,6 +53,7 @@ export interface Rating extends SyncEntity {
historyEntryId: string;
personName: string;
value: 1 | 2 | 3 | 4;
comment: string | null;
}
export interface SyncMeta {