diff --git a/help.md b/help.md new file mode 100644 index 0000000..6dfd797 --- /dev/null +++ b/help.md @@ -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 (1–4 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`. diff --git a/ka-note/client/src/app.css b/ka-note/client/src/app.css index 0dc9d02..ed052c4 100644 --- a/ka-note/client/src/app.css +++ b/ka-note/client/src/app.css @@ -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; diff --git a/ka-note/client/src/lib/actions/ratingIndicator.ts b/ka-note/client/src/lib/actions/ratingIndicator.ts new file mode 100644 index 0000000..e7791fc --- /dev/null +++ b/ka-note/client/src/lib/actions/ratingIndicator.ts @@ -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('[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('.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(); + + 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(); + } + }; +} diff --git a/ka-note/client/src/lib/actions/refClick.ts b/ka-note/client/src/lib/actions/refClick.ts index 0202095..dc953b9 100644 --- a/ka-note/client/src/lib/actions/refClick.ts +++ b/ka-note/client/src/lib/actions/refClick.ts @@ -16,6 +16,77 @@ function closePopup() { } } +function findRatingContext(el: HTMLElement): { topicId: string; historyEntryId: string } | null { + const wrapper = el.closest('[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; } } diff --git a/ka-note/client/src/lib/components/ContextPage.svelte b/ka-note/client/src/lib/components/ContextPage.svelte index 61c1b7e..3922e7b 100644 --- a/ka-note/client/src/lib/components/ContextPage.svelte +++ b/ka-note/client/src/lib/components/ContextPage.svelte @@ -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(); + + 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); + }); +
{#if $context} @@ -72,7 +93,19 @@ {:else if activeView === 'dashboard'} + {:else if activeView === 'ratings'} + {/if} {:else}
Context not found.
{/if} +
+ +{#if ratingModal} + ratingModal = null} + /> +{/if} diff --git a/ka-note/client/src/lib/components/DashboardView.svelte b/ka-note/client/src/lib/components/DashboardView.svelte index 2a05895..a362a47 100644 --- a/ka-note/client/src/lib/components/DashboardView.svelte +++ b/ka-note/client/src/lib/components/DashboardView.svelte @@ -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 @@ {#each $activityLog ?? [] as entry} -
+
{entry.date} in {entry.contextName}: {entry.topicTitle}
diff --git a/ka-note/client/src/lib/components/HistoryItem.svelte b/ka-note/client/src/lib/components/HistoryItem.svelte index 2825eb4..abe8d92 100644 --- a/ka-note/client/src/lib/components/HistoryItem.svelte +++ b/ka-note/client/src/lib/components/HistoryItem.svelte @@ -27,7 +27,7 @@ } -
+
[{entry.date}] {#if !editing} diff --git a/ka-note/client/src/lib/components/RatingModal.svelte b/ka-note/client/src/lib/components/RatingModal.svelte new file mode 100644 index 0000000..a9c86c1 --- /dev/null +++ b/ka-note/client/src/lib/components/RatingModal.svelte @@ -0,0 +1,105 @@ + + + + + + +
{ if (e.target === e.currentTarget) onclose(); }} +> +
e.stopPropagation()}> +

+ Bewertung: @{personName} +

+ + {#if loading} +
Loading...
+ {:else} + +
+ {#each RATING_LEVELS as val} + {@const cfg = RATING_CONFIG[val]} + + {/each} +
+ + +
+ + +
+ + +
+ + +
+ {/if} +
+
diff --git a/ka-note/client/src/lib/components/RatingsView.svelte b/ka-note/client/src/lib/components/RatingsView.svelte new file mode 100644 index 0000000..12ebe4d --- /dev/null +++ b/ka-note/client/src/lib/components/RatingsView.svelte @@ -0,0 +1,163 @@ + + + +{#if summary} +
+
+ Bewertungen Zusammenfassung + {summary.total} Bewertung{summary.total !== 1 ? 'en' : ''} — Durchschnitt: {summary.avg} +
+
+ {#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} +
+
+
+
{val}: {count}
+
+ {/each} +
+
+{/if} + + +{#if grouped.length === 0} +
Keine Bewertungen vorhanden.
+{:else} + {#each grouped as yg (yg.year)} +
+

{yg.year}

+ {#each yg.months as mg (mg.month)} +
+

{mg.label}

+ {#each mg.items as item (item.rating.id)} + {@const cfg = RATING_CONFIG[item.rating.value]} +
+ {item.rating.value} +
+
+ {item.date} + | + {item.contextName} + | + {item.topicTitle} +
+
{cfg.label}
+ {#if item.rating.comment} +
{item.rating.comment}
+ {/if} +
{item.entryPreview}
+
+
+ + +
+
+ {/each} +
+ {/each} +
+ {/each} +{/if} diff --git a/ka-note/client/src/lib/components/RenderedMarkdown.svelte b/ka-note/client/src/lib/components/RenderedMarkdown.svelte index cf8112a..753e89d 100644 --- a/ka-note/client/src/lib/components/RenderedMarkdown.svelte +++ b/ka-note/client/src/lib/components/RenderedMarkdown.svelte @@ -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 @@ }); -
{@html html}
+
{@html html}
diff --git a/ka-note/client/src/lib/components/ViewTabs.svelte b/ka-note/client/src/lib/components/ViewTabs.svelte index d40fffd..0f4c043 100644 --- a/ka-note/client/src/lib/components/ViewTabs.svelte +++ b/ka-note/client/src/lib/components/ViewTabs.svelte @@ -16,6 +16,8 @@ const isDailyLog = $derived(context.id === 'daily-log'); + const isPerson = $derived(context.type === 'person'); + const tabs = $derived( 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' }] : []) + ] ); diff --git a/ka-note/client/src/lib/db/repositories.ts b/ka-note/client/src/lib/db/repositories.ts index 9e17835..da3f092 100644 --- a/ka-note/client/src/lib/db/repositories.ts +++ b/ka-note/client/src/lib/db/repositories.ts @@ -240,8 +240,8 @@ export async function toggleHistoryEntryDone(id: string): Promise { export async function getRating(topicId: string, historyEntryId: string, personName: string): Promise { 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 { +export async function upsertRating(topicId: string, historyEntryId: string, personName: string, value: 1 | 2 | 3 | 4, comment: string | null = null): Promise { 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 { + return db.ratings + .where('personName').equals(personName) + .filter(r => !r.deletedAt) + .toArray(); +} + +export async function softDeleteRating(id: string): Promise { + const rating = await db.ratings.get(id); + if (rating) { + await db.ratings.update(id, { deletedAt: now(), updatedAt: now(), version: rating.version + 1 }); + } +} diff --git a/ka-note/client/src/lib/db/schema.ts b/ka-note/client/src/lib/db/schema.ts index c79797d..ae68e0e 100644 --- a/ka-note/client/src/lib/db/schema.ts +++ b/ka-note/client/src/lib/db/schema.ts @@ -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; + }); + }); } } diff --git a/ka-note/client/src/lib/stores/agenda.ts b/ka-note/client/src/lib/stores/agenda.ts index a577c8f..9d40983 100644 --- a/ka-note/client/src/lib/stores/agenda.ts +++ b/ka-note/client/src/lib/stores/agenda.ts @@ -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() + ); +} diff --git a/ka-note/client/src/lib/utils/ratingConfig.ts b/ka-note/client/src/lib/utils/ratingConfig.ts new file mode 100644 index 0000000..a4b4d77 --- /dev/null +++ b/ka-note/client/src/lib/utils/ratingConfig.ts @@ -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 = { + 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; +} diff --git a/ka-note/shared/src/types.ts b/ka-note/shared/src/types.ts index f18426b..ef5a9dc 100644 --- a/ka-note/shared/src/types.ts +++ b/ka-note/shared/src/types.ts @@ -53,6 +53,7 @@ export interface Rating extends SyncEntity { historyEntryId: string; personName: string; value: 1 | 2 | 3 | 4; + comment: string | null; } export interface SyncMeta {