ui feautres
This commit is contained in:
parent
867cffb6a1
commit
1acdc0b4cd
|
|
@ -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`.
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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 →</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"
|
||||
>×</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -53,6 +53,7 @@ export interface Rating extends SyncEntity {
|
|||
historyEntryId: string;
|
||||
personName: string;
|
||||
value: 1 | 2 | 3 | 4;
|
||||
comment: string | null;
|
||||
}
|
||||
|
||||
export interface SyncMeta {
|
||||
|
|
|
|||
Loading…
Reference in New Issue