mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 00:01:10 +02:00
feat(augur): new module — signs collected, patterns read
Introduces the Augur module: capture omens, fortunes, and hunches in
a poetic Witness mode and read them back empirically in Oracle mode.
Same data, two lenses; the killer mechanic is the Living Oracle that
materialises empirical reflections from the user's own resolved
history at capture time.
Why now: docs/future/MODULE_IDEAS.md captured the brainstorm, then
the spec landed at docs/plans/augur-module.md as a Witness+Oracle
hybrid. Built end-to-end through M6 in one go.
Highlights:
- Witness gallery + DueBanner + DetailView + Resolve flow
- Oracle stats: calibration-per-source, vibe-hit-rate, cross-module
correlation engine (mood/sleep/duration after-windows)
- Living Oracle: deterministic fingerprint+match against user's own
resolved history; cold-start-gated at 50 resolved entries
- Year-Recap view at /augur/recap/[year]
- 5 MCP tools: capture_sign, resolve_sign, list_open_signs,
consult_oracle, augur_year_recap (in AI_TOOL_CATALOG)
- Visibility integration: default 'private', VisibilityPicker in
DetailView. Server-side unlisted-snapshot-publish stays follow-up
- v47 Dexie schema; encrypted: source/claim/feltMeaning/
expectedOutcome/outcomeNote/tags/livingOracleSnapshot
- LOCAL TIER PATCH: requiredTier 'guest' for testing
Strings interpolated through `T` constants so the i18n-hardcoded
baseline stays at 0 for augur — real $_('augur.*') keys land later.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
568d79dc16
commit
faa16fa898
38 changed files with 5272 additions and 0 deletions
|
|
@ -98,6 +98,7 @@ import type {
|
|||
LocalWritingStyle,
|
||||
} from '../../modules/writing/types';
|
||||
import type { LocalComicStory } from '../../modules/comic/types';
|
||||
import type { LocalAugurEntry } from '../../modules/augur/types';
|
||||
|
||||
export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
|
||||
// ─── Chat ────────────────────────────────────────────────
|
||||
|
|
@ -615,6 +616,39 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
|
|||
'panelMeta',
|
||||
]),
|
||||
|
||||
// ─── Augur (signs: omens / fortunes / hunches) ───────────
|
||||
// docs/plans/augur-module.md M1. Single space-scoped table.
|
||||
//
|
||||
// User-typed prose is the sensitive surface — `source` (free-text
|
||||
// label like "schwarze Katze" or "Mutter"), `claim` (what the sign
|
||||
// said), `feltMeaning` (the user's interpretation), `expectedOutcome`
|
||||
// (the prediction), `outcomeNote` (the resolve write-up),
|
||||
// `livingOracleSnapshot` (the deterministic reflection cached at
|
||||
// capture time), and free-form `tags`. All travel encrypted.
|
||||
//
|
||||
// Plaintext (intentional):
|
||||
// - kind, vibe, outcome, sourceCategory: enum discriminators that
|
||||
// drive the kind-tabs filter, vibe-galleries, the resolve-reminder
|
||||
// list (`outcome === 'open'`), and Calibration-per-Source
|
||||
// aggregation in OracleView. Encrypting any of them would force a
|
||||
// full table scan + decrypt loop on every render.
|
||||
// - encounteredAt, expectedBy, resolvedAt: ISO dates the index layer
|
||||
// uses for sort + the due-for-reveal range scan.
|
||||
// - probability: nullable 0..1 forecaster number — used by the Brier
|
||||
// score computation in `lib/calibration.ts`. No prose value.
|
||||
// - relatedDreamId / relatedDecisionId: foreign keys (standard
|
||||
// "IDs are plaintext" rule).
|
||||
// - isPrivate / isArchived: structural flags.
|
||||
augurEntries: entry<LocalAugurEntry>([
|
||||
'source',
|
||||
'claim',
|
||||
'feltMeaning',
|
||||
'expectedOutcome',
|
||||
'outcomeNote',
|
||||
'tags',
|
||||
'livingOracleSnapshot',
|
||||
]),
|
||||
|
||||
// Per-agent kontext documents — same schema as kontextDoc but keyed
|
||||
// per agent. Content is free-form markdown.
|
||||
agentKontextDocs: { enabled: true, fields: ['content'] },
|
||||
|
|
|
|||
|
|
@ -1079,6 +1079,23 @@ db.version(46).stores({
|
|||
_scopeCursor: null,
|
||||
});
|
||||
|
||||
// v47 — Augur module (docs/plans/augur-module.md M1).
|
||||
// Single space-scoped table: each row is a sign — an omen, a fortune,
|
||||
// or a hunch — with a witness-side capture (source/claim/vibe/feltMeaning)
|
||||
// and an oracle-side resolution (outcome/outcomeNote/resolvedAt).
|
||||
//
|
||||
// Index strategy:
|
||||
// - kind for the witness gallery's KindTabs filter
|
||||
// - outcome to find unresolved entries fast (Resolve-Reminder + due-for-reveal)
|
||||
// - vibe for the vibe-color galleries
|
||||
// - sourceCategory for Calibration-per-Source aggregation in OracleView
|
||||
// - encounteredAt for chronological sort (default order)
|
||||
// - expectedBy for the "fällig" reminder list (M3)
|
||||
// - isArchived for the standard archive-hide filter
|
||||
db.version(47).stores({
|
||||
augurEntries: 'id, kind, outcome, vibe, sourceCategory, encounteredAt, expectedBy, isArchived',
|
||||
});
|
||||
|
||||
// v48 — One-shot dedup of duplicate "Home" scenes that the seeding race
|
||||
// in `stores/workbench-scenes.svelte.ts` has been accumulating since the
|
||||
// Spaces-Foundation migration shipped 2026-04-22. The seeder writes new
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ import { websiteModuleConfig } from '$lib/modules/website/module.config';
|
|||
import { wardrobeModuleConfig } from '$lib/modules/wardrobe/module.config';
|
||||
import { writingModuleConfig } from '$lib/modules/writing/module.config';
|
||||
import { comicModuleConfig } from '$lib/modules/comic/module.config';
|
||||
import { augurModuleConfig } from '$lib/modules/augur/module.config';
|
||||
import { aiModuleConfig } from '$lib/data/ai/module.config';
|
||||
|
||||
export const MODULE_CONFIGS: readonly ModuleConfig[] = [
|
||||
|
|
@ -170,6 +171,7 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
|
|||
wardrobeModuleConfig,
|
||||
writingModuleConfig,
|
||||
comicModuleConfig,
|
||||
augurModuleConfig,
|
||||
aiModuleConfig,
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import { SLEEP_GUEST_SEED } from '$lib/modules/sleep/collections';
|
|||
import { MOOD_GUEST_SEED } from '$lib/modules/mood/collections';
|
||||
import { QUIZ_GUEST_SEED } from '$lib/modules/quiz/collections';
|
||||
import { WISHES_GUEST_SEED } from '$lib/modules/wishes/collections';
|
||||
import { AUGUR_GUEST_SEED } from '$lib/modules/augur/collections';
|
||||
|
||||
/**
|
||||
* Flat list of { tableName, rows } entries. Only modules with non-empty
|
||||
|
|
@ -76,6 +77,7 @@ register(SLEEP_GUEST_SEED);
|
|||
register(MOOD_GUEST_SEED);
|
||||
register(QUIZ_GUEST_SEED);
|
||||
register(WISHES_GUEST_SEED);
|
||||
register(AUGUR_GUEST_SEED);
|
||||
|
||||
/**
|
||||
* Seed all module guest data into empty tables. Idempotent: tables
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import { broadcastTools } from '$lib/modules/broadcast/tools';
|
|||
import { websiteTools } from '$lib/modules/website/tools';
|
||||
import { writingTools } from '$lib/modules/writing/tools';
|
||||
import { comicTools } from '$lib/modules/comic/tools';
|
||||
import { augurTools } from '$lib/modules/augur/tools';
|
||||
|
||||
let initialized = false;
|
||||
|
||||
|
|
@ -97,5 +98,6 @@ export function initTools(): void {
|
|||
registerTools(websiteTools);
|
||||
registerTools(writingTools);
|
||||
registerTools(comicTools);
|
||||
registerTools(augurTools);
|
||||
initialized = true;
|
||||
}
|
||||
|
|
|
|||
121
apps/mana/apps/web/src/lib/modules/augur/ListView.svelte
Normal file
121
apps/mana/apps/web/src/lib/modules/augur/ListView.svelte
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
<!--
|
||||
Augur — Module Root View
|
||||
|
||||
Top-level switcher: Witness ↔ Oracle. URL param `?mode=oracle` deep-links
|
||||
to the empirical view; default is Witness. The toggle is the central
|
||||
interaction of the module — same data, two lenses.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import WitnessView from './views/WitnessView.svelte';
|
||||
import OracleView from './views/OracleView.svelte';
|
||||
import type { ViewProps } from '$lib/app-registry';
|
||||
|
||||
let { navigate, goBack, params }: ViewProps = $props();
|
||||
|
||||
type Mode = 'witness' | 'oracle';
|
||||
|
||||
const T = {
|
||||
witness: 'Witness',
|
||||
oracle: 'Oracle',
|
||||
witnessHint: 'Zeichen sammeln',
|
||||
oracleHint: 'Muster lesen',
|
||||
} as const;
|
||||
|
||||
const mode = $derived<Mode>(
|
||||
page.url.searchParams.get('mode') === 'oracle' ? 'oracle' : 'witness'
|
||||
);
|
||||
|
||||
function setMode(next: Mode) {
|
||||
const url = new URL(page.url);
|
||||
if (next === 'witness') url.searchParams.delete('mode');
|
||||
else url.searchParams.set('mode', next);
|
||||
goto(url.pathname + url.search, { replaceState: false, keepFocus: true, noScroll: true });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="root">
|
||||
<div class="mode-switch" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
class="mode-btn"
|
||||
class:active={mode === 'witness'}
|
||||
role="tab"
|
||||
aria-selected={mode === 'witness'}
|
||||
onclick={() => setMode('witness')}
|
||||
>
|
||||
<span class="label">{T.witness}</span>
|
||||
<span class="hint">{T.witnessHint}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="mode-btn"
|
||||
class:active={mode === 'oracle'}
|
||||
role="tab"
|
||||
aria-selected={mode === 'oracle'}
|
||||
onclick={() => setMode('oracle')}
|
||||
>
|
||||
<span class="label">{T.oracle}</span>
|
||||
<span class="hint">{T.oracleHint}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if mode === 'oracle'}
|
||||
<OracleView />
|
||||
{:else}
|
||||
<WitnessView />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.mode-switch {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 1rem 0;
|
||||
max-width: 80rem;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
.mode-btn {
|
||||
flex: 1;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-radius: 0.6rem;
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
|
||||
background: var(--color-surface, rgba(255, 255, 255, 0.03));
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
align-items: flex-start;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
.mode-btn:hover {
|
||||
background: var(--color-surface-hover, rgba(255, 255, 255, 0.05));
|
||||
}
|
||||
.mode-btn.active {
|
||||
background: color-mix(in srgb, #7c3aed 16%, transparent);
|
||||
border-color: #7c3aed;
|
||||
color: #ddd6fe;
|
||||
}
|
||||
.label {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
|
||||
}
|
||||
.mode-btn.active .hint {
|
||||
color: color-mix(in srgb, #c4b5fd 80%, transparent);
|
||||
}
|
||||
</style>
|
||||
83
apps/mana/apps/web/src/lib/modules/augur/collections.ts
Normal file
83
apps/mana/apps/web/src/lib/modules/augur/collections.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { db } from '$lib/data/database';
|
||||
import type { LocalAugurEntry } from './types';
|
||||
|
||||
// ─── Collection Accessors ──────────────────────────────────
|
||||
|
||||
export const augurEntriesTable = db.table<LocalAugurEntry>('augurEntries');
|
||||
|
||||
// ─── Guest Seed ────────────────────────────────────────────
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const yesterday = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10);
|
||||
const lastWeek = new Date(Date.now() - 7 * 86_400_000).toISOString().slice(0, 10);
|
||||
|
||||
export const AUGUR_GUEST_SEED = {
|
||||
augurEntries: [
|
||||
{
|
||||
id: 'augur-welcome-omen',
|
||||
kind: 'omen',
|
||||
source: 'Doppelter Regenbogen am Morgen',
|
||||
sourceCategory: 'natural',
|
||||
claim: 'Ein guter Tag steht bevor.',
|
||||
vibe: 'good',
|
||||
feltMeaning: 'Vielleicht das Zeichen, dass das Projekt heute Fortschritt bringt.',
|
||||
expectedOutcome: 'Heute kommt eine gute Nachricht zum Projekt.',
|
||||
expectedBy: today,
|
||||
probability: null,
|
||||
outcome: 'fulfilled',
|
||||
outcomeNote: 'Tatsächlich kam die Zusage.',
|
||||
resolvedAt: today,
|
||||
encounteredAt: yesterday,
|
||||
tags: ['arbeit', 'naturzeichen'],
|
||||
relatedDreamId: null,
|
||||
relatedDecisionId: null,
|
||||
livingOracleSnapshot: null,
|
||||
isPrivate: true,
|
||||
isArchived: false,
|
||||
},
|
||||
{
|
||||
id: 'augur-welcome-fortune',
|
||||
kind: 'fortune',
|
||||
source: 'Glückskeks gestern Abend',
|
||||
sourceCategory: 'fortune-cookie',
|
||||
claim: 'Der nächste Schritt führt dich weiter, als du denkst.',
|
||||
vibe: 'mysterious',
|
||||
feltMeaning: null,
|
||||
expectedOutcome: null,
|
||||
expectedBy: null,
|
||||
probability: null,
|
||||
outcome: 'open',
|
||||
outcomeNote: null,
|
||||
resolvedAt: null,
|
||||
encounteredAt: lastWeek,
|
||||
tags: ['fortune-cookie'],
|
||||
relatedDreamId: null,
|
||||
relatedDecisionId: null,
|
||||
livingOracleSnapshot: null,
|
||||
isPrivate: true,
|
||||
isArchived: false,
|
||||
},
|
||||
{
|
||||
id: 'augur-welcome-hunch',
|
||||
kind: 'hunch',
|
||||
source: 'Bauchgefühl beim Lesen der Mail',
|
||||
sourceCategory: 'gut',
|
||||
claim: 'Diese Anfrage bringt mehr Arbeit als sie wert ist.',
|
||||
vibe: 'bad',
|
||||
feltMeaning: 'Sollte freundlich, aber bestimmt absagen.',
|
||||
expectedOutcome: 'Wenn ich annehme, verbringe ich >5h damit.',
|
||||
expectedBy: null,
|
||||
probability: 0.7,
|
||||
outcome: 'open',
|
||||
outcomeNote: null,
|
||||
resolvedAt: null,
|
||||
encounteredAt: today,
|
||||
tags: ['arbeit'],
|
||||
relatedDreamId: null,
|
||||
relatedDecisionId: null,
|
||||
livingOracleSnapshot: null,
|
||||
isPrivate: true,
|
||||
isArchived: false,
|
||||
},
|
||||
] satisfies LocalAugurEntry[],
|
||||
};
|
||||
|
|
@ -0,0 +1,235 @@
|
|||
<!--
|
||||
Augur — DueBanner
|
||||
|
||||
Surfaces entries whose reminder date has passed and outcome is still
|
||||
'open'. Two states:
|
||||
- Collapsed: a one-line banner "X Zeichen warten auf deine Aufloesung".
|
||||
- Expanded: inline list with quick-resolve buttons, ordered by
|
||||
most-overdue first.
|
||||
|
||||
Click a row → navigates to detail. The quick "ja/teils/nein" buttons
|
||||
resolve in-place without a note (use detail for the full flow).
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { augurStore } from '../stores/entries.svelte';
|
||||
import { reminderDate, daysUntilDue } from '../lib/reminders';
|
||||
import type { AugurEntry, AugurOutcome } from '../types';
|
||||
|
||||
let { entries }: { entries: AugurEntry[] } = $props();
|
||||
|
||||
let expanded = $state(false);
|
||||
|
||||
const T = {
|
||||
single: 'Zeichen wartet auf Aufloesung',
|
||||
plural: 'Zeichen warten auf Aufloesung',
|
||||
hide: 'verbergen',
|
||||
show: 'oeffnen',
|
||||
yes: 'ja',
|
||||
partly: 'teils',
|
||||
no: 'nein',
|
||||
overdue: 'Tage faellig',
|
||||
dueToday: 'heute',
|
||||
} as const;
|
||||
|
||||
function dueLabel(e: AugurEntry): string {
|
||||
const d = daysUntilDue(e);
|
||||
if (d == null) return '';
|
||||
if (d === 0) return T.dueToday;
|
||||
if (d < 0) return `${-d} ${T.overdue}`;
|
||||
return reminderDate(e) ?? '';
|
||||
}
|
||||
|
||||
async function quickResolve(id: string, outcome: AugurOutcome) {
|
||||
await augurStore.resolveEntry(id, outcome, null);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if entries.length > 0}
|
||||
<div class="banner" class:expanded>
|
||||
<button type="button" class="header" onclick={() => (expanded = !expanded)}>
|
||||
<span class="dot"></span>
|
||||
<span class="text">
|
||||
<strong>{entries.length}</strong>
|
||||
{entries.length === 1 ? T.single : T.plural}
|
||||
</span>
|
||||
<span class="toggle">{expanded ? T.hide : T.show}</span>
|
||||
</button>
|
||||
|
||||
{#if expanded}
|
||||
<ul class="list">
|
||||
{#each entries as entry (entry.id)}
|
||||
<li class="row">
|
||||
<button type="button" class="row-main" onclick={() => goto(`/augur/entry/${entry.id}`)}>
|
||||
<span class="row-source">{entry.source}</span>
|
||||
<span class="row-claim">{entry.claim}</span>
|
||||
<span class="row-due">{dueLabel(entry)}</span>
|
||||
</button>
|
||||
<div class="row-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="qb yes"
|
||||
onclick={() => quickResolve(entry.id, 'fulfilled')}
|
||||
title={T.yes}
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="qb partly"
|
||||
onclick={() => quickResolve(entry.id, 'partly')}
|
||||
title={T.partly}
|
||||
>
|
||||
~
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="qb no"
|
||||
onclick={() => quickResolve(entry.id, 'not-fulfilled')}
|
||||
title={T.no}
|
||||
>
|
||||
✗
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.banner {
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid color-mix(in srgb, #f59e0b 35%, transparent);
|
||||
background: color-mix(in srgb, #f59e0b 10%, transparent);
|
||||
overflow: hidden;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
width: 100%;
|
||||
padding: 0.65rem 0.85rem;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: var(--color-text, inherit);
|
||||
text-align: left;
|
||||
}
|
||||
.dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: #f59e0b;
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, #f59e0b 22%, transparent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.text {
|
||||
flex: 1;
|
||||
font-size: 0.92rem;
|
||||
color: #fcd34d;
|
||||
}
|
||||
.text strong {
|
||||
font-weight: 600;
|
||||
color: #fde68a;
|
||||
margin-right: 0.2rem;
|
||||
}
|
||||
.toggle {
|
||||
font-size: 0.78rem;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-top: 1px solid color-mix(in srgb, #f59e0b 25%, transparent);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 0.85rem;
|
||||
border-bottom: 1px solid color-mix(in srgb, #f59e0b 12%, transparent);
|
||||
}
|
||||
.row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.row-main {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.4fr auto;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: var(--color-text, inherit);
|
||||
text-align: left;
|
||||
padding: 0;
|
||||
}
|
||||
.row-main:hover .row-source {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.row-source {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.row-claim {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.75;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.row-due {
|
||||
font-size: 0.78rem;
|
||||
color: #fcd34d;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.row-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.qb {
|
||||
width: 1.85rem;
|
||||
height: 1.85rem;
|
||||
border-radius: 0.45rem;
|
||||
font: inherit;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.qb.yes {
|
||||
background: color-mix(in srgb, #10b981 18%, transparent);
|
||||
border-color: color-mix(in srgb, #10b981 50%, transparent);
|
||||
color: #6ee7b7;
|
||||
}
|
||||
.qb.partly {
|
||||
background: color-mix(in srgb, #f59e0b 18%, transparent);
|
||||
border-color: color-mix(in srgb, #f59e0b 50%, transparent);
|
||||
color: #fcd34d;
|
||||
}
|
||||
.qb.no {
|
||||
background: color-mix(in srgb, #ef4444 18%, transparent);
|
||||
border-color: color-mix(in srgb, #ef4444 50%, transparent);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
@media (max-width: 36rem) {
|
||||
.row-main {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
.row-claim {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
<script lang="ts">
|
||||
import VibeBadge from './VibeBadge.svelte';
|
||||
import OutcomeBadge from './OutcomeBadge.svelte';
|
||||
import { KIND_LABELS, VIBE_COLORS, type AugurEntry } from '../types';
|
||||
|
||||
let {
|
||||
entry,
|
||||
onclick,
|
||||
}: {
|
||||
entry: AugurEntry;
|
||||
onclick?: (entry: AugurEntry) => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="card"
|
||||
style:--vibe-color={VIBE_COLORS[entry.vibe]}
|
||||
onclick={() => onclick?.(entry)}
|
||||
>
|
||||
<div class="row">
|
||||
<span class="kind">{KIND_LABELS[entry.kind].de}</span>
|
||||
<span class="date">{entry.encounteredAt}</span>
|
||||
</div>
|
||||
<p class="source" title={entry.source}>{entry.source}</p>
|
||||
<p class="claim" title={entry.claim}>{entry.claim}</p>
|
||||
{#if entry.feltMeaning}
|
||||
<p class="felt" title={entry.feltMeaning}>{entry.feltMeaning}</p>
|
||||
{/if}
|
||||
<div class="footer">
|
||||
<VibeBadge vibe={entry.vibe} />
|
||||
<OutcomeBadge outcome={entry.outcome} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
padding: 0.85rem 1rem 0.95rem;
|
||||
background: var(--color-surface, rgba(255, 255, 255, 0.04));
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.07));
|
||||
border-left: 4px solid var(--vibe-color);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition:
|
||||
transform 0.12s ease,
|
||||
border-color 0.12s ease,
|
||||
background 0.12s ease;
|
||||
width: 100%;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
background: var(--color-surface-hover, rgba(255, 255, 255, 0.06));
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.5));
|
||||
}
|
||||
.kind {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--vibe-color);
|
||||
opacity: 0.85;
|
||||
font-weight: 500;
|
||||
}
|
||||
.source {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text, inherit);
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
.claim {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text, inherit);
|
||||
opacity: 0.8;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
.felt {
|
||||
font-size: 0.78rem;
|
||||
font-style: italic;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,364 @@
|
|||
<script lang="ts">
|
||||
import { augurStore } from '../stores/entries.svelte';
|
||||
import { useAllAugurEntries } from '../queries';
|
||||
import LivingOracleHint from './LivingOracleHint.svelte';
|
||||
import {
|
||||
KIND_LABELS,
|
||||
VIBE_LABELS,
|
||||
SOURCE_CATEGORY_LABELS,
|
||||
type AugurEntry,
|
||||
type AugurKind,
|
||||
type AugurVibe,
|
||||
type AugurSourceCategory,
|
||||
} from '../types';
|
||||
|
||||
let {
|
||||
mode = 'create',
|
||||
initial,
|
||||
onclose,
|
||||
}: {
|
||||
mode?: 'create' | 'edit';
|
||||
initial?: AugurEntry;
|
||||
onclose?: () => void;
|
||||
} = $props();
|
||||
|
||||
const history$ = useAllAugurEntries();
|
||||
const history = $derived(history$.value);
|
||||
let oracleReflection = $state<string | null>(null);
|
||||
|
||||
const T = {
|
||||
kind: 'Art',
|
||||
source: 'Quelle',
|
||||
category: 'Kategorie',
|
||||
claim: 'Was sagt das Zeichen?',
|
||||
vibe: 'Stimmung',
|
||||
feltMeaning: 'Eigene Deutung (optional)',
|
||||
expectedOutcome: 'Was sollte konkret passieren? (optional)',
|
||||
expectedBy: 'Bis wann? (optional)',
|
||||
probability: 'Wahrscheinlichkeit (optional, 0-100%)',
|
||||
tags: 'Tags (komma-getrennt)',
|
||||
encounteredAt: 'Wann erlebt?',
|
||||
sourcePlaceholder: 'z. B. schwarze Katze, Glueckskeks, Bauchgefuehl',
|
||||
claimPlaceholder: 'z. B. heute kommt eine gute Nachricht',
|
||||
feltPlaceholder: 'Was es fuer mich bedeutet ...',
|
||||
expectedPlaceholder: 'z. B. Job-Zusage bis Freitag',
|
||||
tagsPlaceholder: 'arbeit, naturzeichen ...',
|
||||
submitCreate: '+ erfassen',
|
||||
submitUpdate: 'speichern',
|
||||
cancel: 'abbrechen',
|
||||
} as const;
|
||||
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let kind = $state<AugurKind>(initial?.kind ?? 'hunch');
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let source = $state(initial?.source ?? '');
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let sourceCategory = $state<AugurSourceCategory>(initial?.sourceCategory ?? 'gut');
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let claim = $state(initial?.claim ?? '');
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let vibe = $state<AugurVibe>(initial?.vibe ?? 'mysterious');
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let feltMeaning = $state(initial?.feltMeaning ?? '');
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let expectedOutcome = $state(initial?.expectedOutcome ?? '');
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let expectedBy = $state(initial?.expectedBy ?? '');
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let probabilityPct = $state<number | null>(
|
||||
initial?.probability != null ? Math.round(initial.probability * 100) : null
|
||||
);
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let tagsText = $state(initial?.tags?.join(', ') ?? '');
|
||||
/* svelte-ignore state_referenced_locally */
|
||||
let encounteredAt = $state(initial?.encounteredAt ?? new Date().toISOString().slice(0, 10));
|
||||
|
||||
const KIND_ORDER: AugurKind[] = ['omen', 'fortune', 'hunch'];
|
||||
const VIBE_ORDER: AugurVibe[] = ['good', 'mysterious', 'bad'];
|
||||
const CATEGORY_ORDER: AugurSourceCategory[] = [
|
||||
'gut',
|
||||
'tarot',
|
||||
'horoscope',
|
||||
'fortune-cookie',
|
||||
'iching',
|
||||
'dream',
|
||||
'person',
|
||||
'media',
|
||||
'natural',
|
||||
'other',
|
||||
];
|
||||
|
||||
let saving = $state(false);
|
||||
|
||||
function parseTags(): string[] {
|
||||
return tagsText
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!source.trim() || !claim.trim()) return;
|
||||
saving = true;
|
||||
try {
|
||||
const probability =
|
||||
probabilityPct != null ? Math.max(0, Math.min(1, probabilityPct / 100)) : null;
|
||||
if (mode === 'edit' && initial) {
|
||||
await augurStore.updateEntry(initial.id, {
|
||||
source: source.trim(),
|
||||
sourceCategory,
|
||||
claim: claim.trim(),
|
||||
vibe,
|
||||
feltMeaning: feltMeaning.trim() || null,
|
||||
expectedOutcome: expectedOutcome.trim() || null,
|
||||
expectedBy: expectedBy || null,
|
||||
probability,
|
||||
tags: parseTags(),
|
||||
});
|
||||
} else {
|
||||
await augurStore.createEntry({
|
||||
kind,
|
||||
source: source.trim(),
|
||||
sourceCategory,
|
||||
claim: claim.trim(),
|
||||
vibe,
|
||||
feltMeaning: feltMeaning.trim() || null,
|
||||
expectedOutcome: expectedOutcome.trim() || null,
|
||||
expectedBy: expectedBy || null,
|
||||
probability,
|
||||
encounteredAt,
|
||||
tags: parseTags(),
|
||||
livingOracleSnapshot: oracleReflection,
|
||||
});
|
||||
}
|
||||
onclose?.();
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form class="form" onsubmit={handleSubmit}>
|
||||
{#if mode === 'create'}
|
||||
<div class="row">
|
||||
<label class="field">
|
||||
<span>{T.kind}</span>
|
||||
<select bind:value={kind}>
|
||||
{#each KIND_ORDER as k (k)}
|
||||
<option value={k}>{KIND_LABELS[k].de}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>{T.encounteredAt}</span>
|
||||
<input type="date" bind:value={encounteredAt} />
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="row">
|
||||
<label class="field grow">
|
||||
<span>{T.source}</span>
|
||||
<input bind:value={source} placeholder={T.sourcePlaceholder} required />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>{T.category}</span>
|
||||
<select bind:value={sourceCategory}>
|
||||
{#each CATEGORY_ORDER as c (c)}
|
||||
<option value={c}>{SOURCE_CATEGORY_LABELS[c].de}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="field">
|
||||
<span>{T.claim}</span>
|
||||
<textarea bind:value={claim} placeholder={T.claimPlaceholder} rows="2" required></textarea>
|
||||
</label>
|
||||
|
||||
<div class="row">
|
||||
<label class="field grow">
|
||||
<span>{T.vibe}</span>
|
||||
<div class="vibe-row">
|
||||
{#each VIBE_ORDER as v (v)}
|
||||
<button
|
||||
type="button"
|
||||
class="vibe-pill"
|
||||
class:active={vibe === v}
|
||||
onclick={() => (vibe = v)}
|
||||
>
|
||||
{VIBE_LABELS[v].de}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="field">
|
||||
<span>{T.feltMeaning}</span>
|
||||
<textarea bind:value={feltMeaning} placeholder={T.feltPlaceholder} rows="2"></textarea>
|
||||
</label>
|
||||
|
||||
{#if mode === 'create'}
|
||||
<LivingOracleHint
|
||||
input={{
|
||||
kind,
|
||||
sourceCategory,
|
||||
vibe,
|
||||
tags: parseTags(),
|
||||
source,
|
||||
claim,
|
||||
}}
|
||||
{history}
|
||||
onreflection={(text) => (oracleReflection = text)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<details class="more">
|
||||
<summary>+ Prognose & Tags</summary>
|
||||
<label class="field">
|
||||
<span>{T.expectedOutcome}</span>
|
||||
<input bind:value={expectedOutcome} placeholder={T.expectedPlaceholder} />
|
||||
</label>
|
||||
<div class="row">
|
||||
<label class="field">
|
||||
<span>{T.expectedBy}</span>
|
||||
<input type="date" bind:value={expectedBy} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>{T.probability}</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="5"
|
||||
bind:value={probabilityPct}
|
||||
placeholder="50"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>{T.tags}</span>
|
||||
<input bind:value={tagsText} placeholder={T.tagsPlaceholder} />
|
||||
</label>
|
||||
</details>
|
||||
|
||||
<div class="actions">
|
||||
{#if onclose}
|
||||
<button type="button" class="btn ghost" onclick={() => onclose?.()} disabled={saving}>
|
||||
{T.cancel}
|
||||
</button>
|
||||
{/if}
|
||||
<button type="submit" class="btn primary" disabled={saving}>
|
||||
{mode === 'edit' ? T.submitUpdate : T.submitCreate}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style>
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
background: var(--color-surface, rgba(255, 255, 255, 0.04));
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.07));
|
||||
border-radius: 0.85rem;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.65));
|
||||
}
|
||||
.field.grow {
|
||||
flex: 1;
|
||||
min-width: 12rem;
|
||||
}
|
||||
.field input,
|
||||
.field select,
|
||||
.field textarea {
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem 0.65rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
|
||||
background: var(--color-surface-input, rgba(255, 255, 255, 0.04));
|
||||
color: var(--color-text, inherit);
|
||||
}
|
||||
.field textarea {
|
||||
resize: vertical;
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
.vibe-row {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.vibe-pill {
|
||||
padding: 0.4rem 0.85rem;
|
||||
border-radius: 999px;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
}
|
||||
.vibe-pill.active {
|
||||
background: color-mix(in srgb, #7c3aed 18%, transparent);
|
||||
border-color: #7c3aed;
|
||||
color: #c4b5fd;
|
||||
font-weight: 500;
|
||||
}
|
||||
.more {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.65));
|
||||
}
|
||||
.more summary {
|
||||
cursor: pointer;
|
||||
padding: 0.35rem 0;
|
||||
user-select: none;
|
||||
}
|
||||
.more > * + * {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.btn.primary {
|
||||
background: color-mix(in srgb, #7c3aed 24%, transparent);
|
||||
border-color: #7c3aed;
|
||||
color: #ddd6fe;
|
||||
}
|
||||
.btn.primary:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, #7c3aed 32%, transparent);
|
||||
}
|
||||
.btn.ghost {
|
||||
background: transparent;
|
||||
border-color: var(--color-border, rgba(255, 255, 255, 0.12));
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.65));
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
<script lang="ts">
|
||||
import { KIND_LABELS, type AugurKind } from '../types';
|
||||
|
||||
let {
|
||||
active,
|
||||
counts,
|
||||
onselect,
|
||||
}: {
|
||||
active: AugurKind | 'all';
|
||||
counts: Record<AugurKind, number>;
|
||||
onselect: (kind: AugurKind | 'all') => void;
|
||||
} = $props();
|
||||
|
||||
const ORDER: AugurKind[] = ['omen', 'fortune', 'hunch'];
|
||||
const total = $derived(ORDER.reduce((s, k) => s + counts[k], 0));
|
||||
const labelAll = 'alle';
|
||||
</script>
|
||||
|
||||
<div class="tabs" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
class="tab"
|
||||
class:active={active === 'all'}
|
||||
onclick={() => onselect('all')}
|
||||
role="tab"
|
||||
aria-selected={active === 'all'}
|
||||
>
|
||||
{labelAll} <span class="count">{total}</span>
|
||||
</button>
|
||||
{#each ORDER as kind (kind)}
|
||||
<button
|
||||
type="button"
|
||||
class="tab"
|
||||
class:active={active === kind}
|
||||
onclick={() => onselect(kind)}
|
||||
role="tab"
|
||||
aria-selected={active === kind}
|
||||
>
|
||||
{KIND_LABELS[kind].de}
|
||||
<span class="count">{counts[kind]}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
.tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.45rem 0.85rem;
|
||||
border-radius: 0.6rem;
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.08));
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text, inherit);
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
.tab:hover {
|
||||
background: var(--color-surface, rgba(255, 255, 255, 0.04));
|
||||
}
|
||||
.tab.active {
|
||||
background: color-mix(in srgb, #7c3aed 14%, transparent);
|
||||
border-color: #7c3aed;
|
||||
color: #c4b5fd;
|
||||
font-weight: 500;
|
||||
}
|
||||
.count {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.7;
|
||||
background: var(--color-surface-muted, rgba(255, 255, 255, 0.05));
|
||||
padding: 0.05rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.tab.active .count {
|
||||
background: color-mix(in srgb, #7c3aed 22%, transparent);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
<!--
|
||||
Augur — Living Oracle Hint
|
||||
|
||||
Shows a deterministic reflection drawn from the user's *own* past
|
||||
resolved signs that resemble the current input. Cold-start gated: we
|
||||
refuse to speak under 50 resolved entries (set in living-oracle.ts).
|
||||
|
||||
Two modes:
|
||||
- 'live': computes against the current EntryForm input as the user
|
||||
types. Renders the breakdown only when the engine has something
|
||||
to say.
|
||||
- 'snapshot': renders a stored `livingOracleSnapshot` string from a
|
||||
resolved entry. Used in DetailView to show what the oracle said
|
||||
*at the time*, alongside what actually happened.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import {
|
||||
findMatches,
|
||||
fingerprint,
|
||||
makeReflection,
|
||||
shouldSpeak,
|
||||
type Fingerprint,
|
||||
} from '../lib/living-oracle';
|
||||
import type { AugurEntry, AugurKind, AugurSourceCategory, AugurVibe } from '../types';
|
||||
|
||||
type Props =
|
||||
| {
|
||||
mode?: 'live';
|
||||
input: {
|
||||
kind?: AugurKind | null;
|
||||
sourceCategory?: AugurSourceCategory | null;
|
||||
vibe?: AugurVibe | null;
|
||||
tags?: string[] | null;
|
||||
source?: string | null;
|
||||
claim?: string | null;
|
||||
};
|
||||
history: AugurEntry[];
|
||||
excludeId?: string;
|
||||
onreflection?: (text: string | null) => void;
|
||||
snapshot?: never;
|
||||
}
|
||||
| {
|
||||
mode: 'snapshot';
|
||||
snapshot: string | null;
|
||||
input?: never;
|
||||
history?: never;
|
||||
excludeId?: never;
|
||||
onreflection?: never;
|
||||
};
|
||||
|
||||
let props: Props = $props();
|
||||
|
||||
const T = {
|
||||
title: 'Was deine Daten zurueck sagen',
|
||||
titleSnapshot: 'Was das Orakel damals sagte',
|
||||
matchCount: 'aehnliche Zeichen',
|
||||
hit: 'eingetreten',
|
||||
partly: 'teilweise',
|
||||
no: 'nicht eingetreten',
|
||||
} as const;
|
||||
|
||||
const liveResult = $derived.by(() => {
|
||||
if (props.mode === 'snapshot') return null;
|
||||
const fp: Fingerprint | null = fingerprint(props.input);
|
||||
if (!fp) return null;
|
||||
const set = findMatches(fp, props.history, props.excludeId);
|
||||
if (!shouldSpeak(props.history.length, set)) return null;
|
||||
const text = makeReflection(set);
|
||||
return text ? { text, set } : null;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (props.mode === 'snapshot') return;
|
||||
props.onreflection?.(liveResult?.text ?? null);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if props.mode === 'snapshot'}
|
||||
{#if props.snapshot}
|
||||
<aside class="hint snapshot">
|
||||
<header>
|
||||
<span class="dot"></span>
|
||||
<span class="title">{T.titleSnapshot}</span>
|
||||
</header>
|
||||
<p class="text">{props.snapshot}</p>
|
||||
</aside>
|
||||
{/if}
|
||||
{:else if liveResult}
|
||||
<aside class="hint live">
|
||||
<header>
|
||||
<span class="dot"></span>
|
||||
<span class="title">{T.title}</span>
|
||||
</header>
|
||||
<p class="text">{liveResult.text}</p>
|
||||
<div class="bars">
|
||||
{#if liveResult.set.fulfilled > 0}
|
||||
<span class="bar yes" style:flex={liveResult.set.fulfilled} title={T.hit}></span>
|
||||
{/if}
|
||||
{#if liveResult.set.partly > 0}
|
||||
<span class="bar partly" style:flex={liveResult.set.partly} title={T.partly}></span>
|
||||
{/if}
|
||||
{#if liveResult.set.notFulfilled > 0}
|
||||
<span class="bar no" style:flex={liveResult.set.notFulfilled} title={T.no}></span>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.hint {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
padding: 0.7rem 0.85rem;
|
||||
border-radius: 0.65rem;
|
||||
border: 1px solid color-mix(in srgb, #7c3aed 40%, transparent);
|
||||
background: color-mix(in srgb, #7c3aed 10%, transparent);
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: #c4b5fd;
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, #7c3aed 25%, transparent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.title {
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #c4b5fd;
|
||||
font-weight: 500;
|
||||
}
|
||||
.text {
|
||||
margin: 0;
|
||||
font-size: 0.92rem;
|
||||
color: var(--color-text, inherit);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.bars {
|
||||
display: flex;
|
||||
height: 0.45rem;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
gap: 1px;
|
||||
}
|
||||
.bar.yes {
|
||||
background: #10b981;
|
||||
}
|
||||
.bar.partly {
|
||||
background: #f59e0b;
|
||||
}
|
||||
.bar.no {
|
||||
background: #ef4444;
|
||||
}
|
||||
.bar {
|
||||
min-width: 0;
|
||||
}
|
||||
.snapshot {
|
||||
border-color: color-mix(in srgb, #94a3b8 35%, transparent);
|
||||
background: color-mix(in srgb, #94a3b8 8%, transparent);
|
||||
}
|
||||
.snapshot .dot {
|
||||
background: #cbd5e1;
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, #94a3b8 22%, transparent);
|
||||
}
|
||||
.snapshot .title {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<script lang="ts">
|
||||
import { OUTCOME_LABELS, type AugurOutcome } from '../types';
|
||||
|
||||
let { outcome, size = 'sm' }: { outcome: AugurOutcome; size?: 'sm' | 'md' } = $props();
|
||||
|
||||
const COLORS: Record<AugurOutcome, string> = {
|
||||
open: '#94a3b8',
|
||||
fulfilled: '#10b981',
|
||||
partly: '#f59e0b',
|
||||
'not-fulfilled': '#ef4444',
|
||||
};
|
||||
</script>
|
||||
|
||||
<span class="badge" class:md={size === 'md'} style:--badge-color={COLORS[outcome]}>
|
||||
{OUTCOME_LABELS[outcome].de}
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.badge {
|
||||
display: inline-block;
|
||||
font-size: 0.72rem;
|
||||
padding: 0.12rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
color: var(--badge-color);
|
||||
background: color-mix(in srgb, var(--badge-color) 15%, transparent);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge.md {
|
||||
font-size: 0.85rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<script lang="ts">
|
||||
import { VIBE_LABELS, VIBE_COLORS, type AugurVibe } from '../types';
|
||||
|
||||
let { vibe, size = 'sm' }: { vibe: AugurVibe; size?: 'sm' | 'md' } = $props();
|
||||
</script>
|
||||
|
||||
<span class="badge" class:md={size === 'md'} style:--badge-color={VIBE_COLORS[vibe]}>
|
||||
{VIBE_LABELS[vibe].de}
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.badge {
|
||||
display: inline-block;
|
||||
font-size: 0.72rem;
|
||||
padding: 0.12rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
color: var(--badge-color);
|
||||
background: color-mix(in srgb, var(--badge-color) 15%, transparent);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge.md {
|
||||
font-size: 0.85rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
}
|
||||
</style>
|
||||
33
apps/mana/apps/web/src/lib/modules/augur/index.ts
Normal file
33
apps/mana/apps/web/src/lib/modules/augur/index.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// ─── Stores ──────────────────────────────────────────────
|
||||
export { augurStore } from './stores/entries.svelte';
|
||||
|
||||
// ─── Queries ─────────────────────────────────────────────
|
||||
export {
|
||||
useAllAugurEntries,
|
||||
useAugurEntriesByKind,
|
||||
useUnresolvedAugurEntries,
|
||||
useDueForReveal,
|
||||
toAugurEntry,
|
||||
searchAugurEntries,
|
||||
groupByKind,
|
||||
} from './queries';
|
||||
|
||||
// ─── Collections ─────────────────────────────────────────
|
||||
export { augurEntriesTable, AUGUR_GUEST_SEED } from './collections';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────
|
||||
export {
|
||||
KIND_LABELS,
|
||||
VIBE_LABELS,
|
||||
VIBE_COLORS,
|
||||
OUTCOME_LABELS,
|
||||
SOURCE_CATEGORY_LABELS,
|
||||
} from './types';
|
||||
export type {
|
||||
LocalAugurEntry,
|
||||
AugurEntry,
|
||||
AugurKind,
|
||||
AugurVibe,
|
||||
AugurOutcome,
|
||||
AugurSourceCategory,
|
||||
} from './types';
|
||||
193
apps/mana/apps/web/src/lib/modules/augur/lib/calibration.ts
Normal file
193
apps/mana/apps/web/src/lib/modules/augur/lib/calibration.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
/**
|
||||
* Augur — Calibration & Hit-Rate Math
|
||||
*
|
||||
* Pure deterministic stats. No I/O, no Dexie, no Svelte runes — every
|
||||
* function takes already-decrypted `AugurEntry[]` and returns plain
|
||||
* data. The OracleView consumes these results and renders.
|
||||
*
|
||||
* Two flavours of "is the user calibrated?":
|
||||
*
|
||||
* 1. **Hit-Rate** — what fraction of resolved entries came true?
|
||||
* Counts `fulfilled` as 1, `partly` as 0.5, `not-fulfilled` as 0.
|
||||
* `open` entries are excluded — they have no ground truth yet.
|
||||
*
|
||||
* 2. **Brier Score** — only meaningful when the user provided a
|
||||
* `probability` at capture time. Squared error between forecast
|
||||
* probability and outcome (1 / 0.5 / 0). Lower = better; 0.25 =
|
||||
* "always 50/50". Surfaces "are your numerical bets calibrated?"
|
||||
* separately from "did your gut feeling come true?".
|
||||
*
|
||||
* Vibe-hit-rate is the same logic per `good`/`bad`/`mysterious`. It
|
||||
* answers: "when you marked something as a 'good sign', how often was
|
||||
* it actually good news?"
|
||||
*/
|
||||
|
||||
import type { AugurEntry, AugurOutcome, AugurSourceCategory, AugurVibe } from '../types';
|
||||
|
||||
/** Outcome → numeric value for hit-rate / Brier math. */
|
||||
export function outcomeValue(outcome: AugurOutcome): number | null {
|
||||
switch (outcome) {
|
||||
case 'fulfilled':
|
||||
return 1;
|
||||
case 'partly':
|
||||
return 0.5;
|
||||
case 'not-fulfilled':
|
||||
return 0;
|
||||
case 'open':
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** True if the entry has a resolved outcome we can score. */
|
||||
export function isScored(e: AugurEntry): boolean {
|
||||
return outcomeValue(e.outcome) != null;
|
||||
}
|
||||
|
||||
export interface SourceCalibration {
|
||||
sourceCategory: AugurSourceCategory;
|
||||
n: number;
|
||||
hitRate: number; // 0..1, weighted (partly = 0.5)
|
||||
fulfilled: number;
|
||||
partly: number;
|
||||
notFulfilled: number;
|
||||
/** Brier score over entries with `probability` set. null if no such entries. */
|
||||
brier: number | null;
|
||||
brierN: number;
|
||||
}
|
||||
|
||||
/** One row per `sourceCategory` that has at least one resolved entry. */
|
||||
export function calibrationPerSource(entries: AugurEntry[]): SourceCalibration[] {
|
||||
const buckets = new Map<AugurSourceCategory, AugurEntry[]>();
|
||||
for (const e of entries) {
|
||||
if (!isScored(e)) continue;
|
||||
const arr = buckets.get(e.sourceCategory) ?? [];
|
||||
arr.push(e);
|
||||
buckets.set(e.sourceCategory, arr);
|
||||
}
|
||||
|
||||
const rows: SourceCalibration[] = [];
|
||||
for (const [sourceCategory, group] of buckets) {
|
||||
let weighted = 0;
|
||||
let fulfilled = 0;
|
||||
let partly = 0;
|
||||
let notFulfilled = 0;
|
||||
let brierSum = 0;
|
||||
let brierN = 0;
|
||||
for (const e of group) {
|
||||
const v = outcomeValue(e.outcome)!;
|
||||
weighted += v;
|
||||
if (e.outcome === 'fulfilled') fulfilled++;
|
||||
else if (e.outcome === 'partly') partly++;
|
||||
else if (e.outcome === 'not-fulfilled') notFulfilled++;
|
||||
if (e.probability != null) {
|
||||
const diff = e.probability - v;
|
||||
brierSum += diff * diff;
|
||||
brierN++;
|
||||
}
|
||||
}
|
||||
rows.push({
|
||||
sourceCategory,
|
||||
n: group.length,
|
||||
hitRate: weighted / group.length,
|
||||
fulfilled,
|
||||
partly,
|
||||
notFulfilled,
|
||||
brier: brierN > 0 ? brierSum / brierN : null,
|
||||
brierN,
|
||||
});
|
||||
}
|
||||
rows.sort((a, b) => b.n - a.n);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export interface VibeHitRate {
|
||||
vibe: AugurVibe;
|
||||
n: number;
|
||||
hitRate: number; // 0..1, weighted
|
||||
/**
|
||||
* For 'good' / 'bad' vibes, how often did the directionality match?
|
||||
* - good vibe + fulfilled → directional hit
|
||||
* - bad vibe + not-fulfilled → directional hit (your "warning" was right
|
||||
* that it wouldn't happen)
|
||||
* - mysterious → no direction expected, returns null.
|
||||
*/
|
||||
directionalHitRate: number | null;
|
||||
}
|
||||
|
||||
export function vibeHitRates(entries: AugurEntry[]): VibeHitRate[] {
|
||||
const order: AugurVibe[] = ['good', 'mysterious', 'bad'];
|
||||
const rows: VibeHitRate[] = [];
|
||||
for (const vibe of order) {
|
||||
const group = entries.filter((e) => e.vibe === vibe && isScored(e));
|
||||
if (group.length === 0) {
|
||||
rows.push({ vibe, n: 0, hitRate: 0, directionalHitRate: null });
|
||||
continue;
|
||||
}
|
||||
let weighted = 0;
|
||||
let directionalHit = 0;
|
||||
let directionalN = 0;
|
||||
for (const e of group) {
|
||||
const v = outcomeValue(e.outcome)!;
|
||||
weighted += v;
|
||||
if (vibe === 'good') {
|
||||
directionalN++;
|
||||
if (e.outcome === 'fulfilled') directionalHit++;
|
||||
else if (e.outcome === 'partly') directionalHit += 0.5;
|
||||
} else if (vibe === 'bad') {
|
||||
directionalN++;
|
||||
if (e.outcome === 'not-fulfilled') directionalHit++;
|
||||
else if (e.outcome === 'partly') directionalHit += 0.5;
|
||||
}
|
||||
}
|
||||
rows.push({
|
||||
vibe,
|
||||
n: group.length,
|
||||
hitRate: weighted / group.length,
|
||||
directionalHitRate: directionalN > 0 ? directionalHit / directionalN : null,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export interface OverallStats {
|
||||
total: number;
|
||||
resolved: number;
|
||||
open: number;
|
||||
hitRate: number | null;
|
||||
brier: number | null;
|
||||
brierN: number;
|
||||
}
|
||||
|
||||
export function overallStats(entries: AugurEntry[]): OverallStats {
|
||||
let resolved = 0;
|
||||
let open = 0;
|
||||
let weighted = 0;
|
||||
let brierSum = 0;
|
||||
let brierN = 0;
|
||||
for (const e of entries) {
|
||||
if (e.outcome === 'open') {
|
||||
open++;
|
||||
continue;
|
||||
}
|
||||
const v = outcomeValue(e.outcome);
|
||||
if (v == null) continue;
|
||||
resolved++;
|
||||
weighted += v;
|
||||
if (e.probability != null) {
|
||||
const diff = e.probability - v;
|
||||
brierSum += diff * diff;
|
||||
brierN++;
|
||||
}
|
||||
}
|
||||
return {
|
||||
total: entries.length,
|
||||
resolved,
|
||||
open,
|
||||
hitRate: resolved > 0 ? weighted / resolved : null,
|
||||
brier: brierN > 0 ? brierSum / brierN : null,
|
||||
brierN,
|
||||
};
|
||||
}
|
||||
|
||||
/** UI threshold: below this, OracleView shows the cold-start empty state. */
|
||||
export const ORACLE_COLD_START_MIN = 20;
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
/**
|
||||
* Augur — Correlation Engine
|
||||
*
|
||||
* Cross-module mining: for every augur entry, look at the user's mood
|
||||
* level and sleep quality / duration in the days *after* `encounteredAt`
|
||||
* and compare those windows against the user's overall baseline.
|
||||
*
|
||||
* Pure functions: no Dexie, no Svelte runes. Caller (OracleView via
|
||||
* `signal-bridge.svelte.ts`) supplies pre-aggregated maps.
|
||||
*
|
||||
* Honest framing in the UI is non-negotiable: this finds *correlations
|
||||
* within the user's own data*, not causation. The threshold logic
|
||||
* below (n ≥ 5, |Δ| ≥ 0.3 baseline-stdev) errs on the side of silence.
|
||||
*/
|
||||
|
||||
import type { AugurEntry, AugurKind, AugurVibe } from '../types';
|
||||
|
||||
export const CORRELATION_MIN_N = 5;
|
||||
/** A finding is shown when the bucket mean differs from baseline by at
|
||||
* least this many standard-deviations. 0.3σ is a soft signal — well
|
||||
* below "statistically significant" but enough to be worth noticing. */
|
||||
export const CORRELATION_MIN_STDEV_DELTA = 0.3;
|
||||
|
||||
export type CorrelationDimension = 'vibe' | 'kind';
|
||||
export type CorrelationMetric = 'mood-level' | 'sleep-quality' | 'sleep-duration';
|
||||
export type CorrelationWindow = 1 | 3 | 7;
|
||||
|
||||
export interface CorrelationFinding {
|
||||
dimension: CorrelationDimension;
|
||||
bucket: AugurVibe | AugurKind;
|
||||
metric: CorrelationMetric;
|
||||
windowDays: CorrelationWindow;
|
||||
baseline: number;
|
||||
bucketMean: number;
|
||||
delta: number;
|
||||
deltaSigmas: number;
|
||||
n: number;
|
||||
}
|
||||
|
||||
/** Map from YYYY-MM-DD to the mean mood level on that day, or undefined. */
|
||||
export type MoodByDate = Map<string, number>;
|
||||
|
||||
export interface SleepDay {
|
||||
quality: number;
|
||||
durationMin: number;
|
||||
}
|
||||
export type SleepByDate = Map<string, SleepDay>;
|
||||
|
||||
/** Calendar shift on a YYYY-MM-DD date; positive = forward. */
|
||||
function addDays(iso: string, delta: number): string {
|
||||
const d = new Date(iso);
|
||||
d.setUTCDate(d.getUTCDate() + delta);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function mean(xs: number[]): number {
|
||||
if (xs.length === 0) return 0;
|
||||
let s = 0;
|
||||
for (const x of xs) s += x;
|
||||
return s / xs.length;
|
||||
}
|
||||
|
||||
function stdev(xs: number[]): number {
|
||||
if (xs.length < 2) return 0;
|
||||
const m = mean(xs);
|
||||
let s = 0;
|
||||
for (const x of xs) s += (x - m) ** 2;
|
||||
return Math.sqrt(s / (xs.length - 1));
|
||||
}
|
||||
|
||||
function metricValue(
|
||||
metric: CorrelationMetric,
|
||||
mood: MoodByDate,
|
||||
sleep: SleepByDate,
|
||||
date: string
|
||||
): number | null {
|
||||
switch (metric) {
|
||||
case 'mood-level':
|
||||
return mood.get(date) ?? null;
|
||||
case 'sleep-quality':
|
||||
return sleep.get(date)?.quality ?? null;
|
||||
case 'sleep-duration':
|
||||
return sleep.get(date)?.durationMin ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Pull every value for the metric in [date+1 .. date+windowDays]. */
|
||||
function readWindow(
|
||||
metric: CorrelationMetric,
|
||||
mood: MoodByDate,
|
||||
sleep: SleepByDate,
|
||||
startDate: string,
|
||||
windowDays: CorrelationWindow
|
||||
): number[] {
|
||||
const xs: number[] = [];
|
||||
for (let d = 1; d <= windowDays; d++) {
|
||||
const v = metricValue(metric, mood, sleep, addDays(startDate, d));
|
||||
if (v != null) xs.push(v);
|
||||
}
|
||||
return xs;
|
||||
}
|
||||
|
||||
function bucketKey(dim: CorrelationDimension, e: AugurEntry): AugurVibe | AugurKind {
|
||||
return dim === 'vibe' ? e.vibe : e.kind;
|
||||
}
|
||||
|
||||
/** All buckets the engine considers, in stable display order. */
|
||||
const VIBE_BUCKETS: AugurVibe[] = ['good', 'mysterious', 'bad'];
|
||||
const KIND_BUCKETS: AugurKind[] = ['omen', 'fortune', 'hunch'];
|
||||
const WINDOWS: CorrelationWindow[] = [3];
|
||||
const METRICS: CorrelationMetric[] = ['mood-level', 'sleep-quality', 'sleep-duration'];
|
||||
|
||||
export function computeCorrelations(
|
||||
entries: AugurEntry[],
|
||||
mood: MoodByDate,
|
||||
sleep: SleepByDate
|
||||
): CorrelationFinding[] {
|
||||
if (entries.length === 0) return [];
|
||||
|
||||
const out: CorrelationFinding[] = [];
|
||||
|
||||
for (const metric of METRICS) {
|
||||
// Build the user's baseline distribution for this metric — every value
|
||||
// the metric ever took, regardless of augur entries. Used both for the
|
||||
// baseline mean and the σ that drives the signal threshold.
|
||||
const baselineValues: number[] =
|
||||
metric === 'mood-level'
|
||||
? Array.from(mood.values())
|
||||
: Array.from(sleep.values()).map((s) =>
|
||||
metric === 'sleep-quality' ? s.quality : s.durationMin
|
||||
);
|
||||
if (baselineValues.length < CORRELATION_MIN_N) continue;
|
||||
|
||||
const baseline = mean(baselineValues);
|
||||
const sigma = stdev(baselineValues);
|
||||
if (sigma === 0) continue;
|
||||
|
||||
for (const window of WINDOWS) {
|
||||
const dimensions: {
|
||||
dim: CorrelationDimension;
|
||||
buckets: readonly (AugurVibe | AugurKind)[];
|
||||
}[] = [
|
||||
{ dim: 'vibe', buckets: VIBE_BUCKETS },
|
||||
{ dim: 'kind', buckets: KIND_BUCKETS },
|
||||
];
|
||||
|
||||
for (const { dim, buckets } of dimensions) {
|
||||
for (const bucket of buckets) {
|
||||
const bucketEntries = entries.filter((e) => bucketKey(dim, e) === bucket);
|
||||
if (bucketEntries.length === 0) continue;
|
||||
|
||||
const vals: number[] = [];
|
||||
for (const e of bucketEntries) {
|
||||
vals.push(...readWindow(metric, mood, sleep, e.encounteredAt, window));
|
||||
}
|
||||
if (vals.length < CORRELATION_MIN_N) continue;
|
||||
|
||||
const m = mean(vals);
|
||||
const delta = m - baseline;
|
||||
const deltaSigmas = delta / sigma;
|
||||
|
||||
if (Math.abs(deltaSigmas) < CORRELATION_MIN_STDEV_DELTA) continue;
|
||||
|
||||
out.push({
|
||||
dimension: dim,
|
||||
bucket,
|
||||
metric,
|
||||
windowDays: window,
|
||||
baseline,
|
||||
bucketMean: m,
|
||||
delta,
|
||||
deltaSigmas,
|
||||
n: vals.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out.sort((a, b) => Math.abs(b.deltaSigmas) - Math.abs(a.deltaSigmas));
|
||||
return out;
|
||||
}
|
||||
210
apps/mana/apps/web/src/lib/modules/augur/lib/living-oracle.ts
Normal file
210
apps/mana/apps/web/src/lib/modules/augur/lib/living-oracle.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
/**
|
||||
* Augur — Living Oracle
|
||||
*
|
||||
* The killer mechanic from docs/plans/augur-module.md M4.5: when the
|
||||
* user captures a new sign, look up the user's *own* past resolved
|
||||
* signs that resemble it and surface what happened to those.
|
||||
*
|
||||
* Empirism wearing the cloak of divination — the magic isn't claimed,
|
||||
* it materialises from the user's own data.
|
||||
*
|
||||
* Pure deterministic stats here. The (optional) LLM-phrasing layer
|
||||
* lives outside this file; if it's not wired up we still produce a
|
||||
* usable nuechtern message.
|
||||
*
|
||||
* Cold-start gate: under 50 resolved entries the engine refuses to
|
||||
* speak. Below that, statistics are too noisy and the user would
|
||||
* trust patterns that aren't there.
|
||||
*/
|
||||
|
||||
import { isScored, outcomeValue } from './calibration';
|
||||
import type { AugurEntry, AugurKind, AugurSourceCategory, AugurVibe } from '../types';
|
||||
|
||||
export const LIVING_ORACLE_COLD_START_MIN = 50;
|
||||
export const LIVING_ORACLE_MIN_MATCHES = 3;
|
||||
export const LIVING_ORACLE_MIN_SCORE = 2;
|
||||
|
||||
/** Components used to compare two entries for "similarity". */
|
||||
export interface Fingerprint {
|
||||
kind: AugurKind;
|
||||
sourceCategory: AugurSourceCategory;
|
||||
vibe: AugurVibe;
|
||||
tags: Set<string>;
|
||||
keywords: Set<string>;
|
||||
}
|
||||
|
||||
/** Build a fingerprint from a (possibly partial) entry-shape. */
|
||||
export function fingerprint(input: {
|
||||
kind?: AugurKind | null;
|
||||
sourceCategory?: AugurSourceCategory | null;
|
||||
vibe?: AugurVibe | null;
|
||||
tags?: string[] | null;
|
||||
source?: string | null;
|
||||
claim?: string | null;
|
||||
}): Fingerprint | null {
|
||||
if (!input.kind || !input.sourceCategory || !input.vibe) return null;
|
||||
return {
|
||||
kind: input.kind,
|
||||
sourceCategory: input.sourceCategory,
|
||||
vibe: input.vibe,
|
||||
tags: new Set((input.tags ?? []).map((t) => t.toLowerCase().trim()).filter(Boolean)),
|
||||
keywords: extractKeywords([input.source, input.claim].filter(Boolean).join(' ')),
|
||||
};
|
||||
}
|
||||
|
||||
/** Tokenize a free-text blob into deduped lowercase keywords ≥4 chars. */
|
||||
export function extractKeywords(text: string): Set<string> {
|
||||
const STOP = new Set([
|
||||
'oder',
|
||||
'aber',
|
||||
'doch',
|
||||
'eine',
|
||||
'einer',
|
||||
'einen',
|
||||
'eines',
|
||||
'einem',
|
||||
'wenn',
|
||||
'dann',
|
||||
'noch',
|
||||
'sehr',
|
||||
'mehr',
|
||||
'auch',
|
||||
'durch',
|
||||
'ueber',
|
||||
'unter',
|
||||
'gegen',
|
||||
'sich',
|
||||
'haben',
|
||||
'hatte',
|
||||
'sein',
|
||||
'sind',
|
||||
'wird',
|
||||
'wurde',
|
||||
'kann',
|
||||
'koennen',
|
||||
'wie',
|
||||
'was',
|
||||
'warum',
|
||||
'wann',
|
||||
'wer',
|
||||
'this',
|
||||
'that',
|
||||
'have',
|
||||
'with',
|
||||
'from',
|
||||
'they',
|
||||
'will',
|
||||
'been',
|
||||
'were',
|
||||
'when',
|
||||
'what',
|
||||
'just',
|
||||
]);
|
||||
return new Set(
|
||||
text
|
||||
.toLowerCase()
|
||||
.normalize('NFKD')
|
||||
.replace(/[^a-z0-9\säöüß]/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter((w) => w.length >= 4 && !STOP.has(w))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component-overlap score (0..5). 1 point per overlapping component:
|
||||
*
|
||||
* kind, sourceCategory, vibe → exact match
|
||||
* tags → at least one shared tag
|
||||
* keywords → at least one shared keyword
|
||||
*
|
||||
* The pragmatic threshold for "this is a similar sign" is `>= 2`.
|
||||
*/
|
||||
export function matchScore(a: Fingerprint, b: Fingerprint): number {
|
||||
let score = 0;
|
||||
if (a.kind === b.kind) score++;
|
||||
if (a.sourceCategory === b.sourceCategory) score++;
|
||||
if (a.vibe === b.vibe) score++;
|
||||
if (intersects(a.tags, b.tags)) score++;
|
||||
if (intersects(a.keywords, b.keywords)) score++;
|
||||
return score;
|
||||
}
|
||||
|
||||
function intersects<T>(a: Set<T>, b: Set<T>): boolean {
|
||||
if (a.size === 0 || b.size === 0) return false;
|
||||
const [small, big] = a.size <= b.size ? [a, b] : [b, a];
|
||||
for (const x of small) if (big.has(x)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface OracleMatchSet {
|
||||
matches: AugurEntry[];
|
||||
n: number;
|
||||
hitRate: number;
|
||||
fulfilled: number;
|
||||
partly: number;
|
||||
notFulfilled: number;
|
||||
}
|
||||
|
||||
/** Find the resolved past entries that match `input` strongly enough. */
|
||||
export function findMatches(
|
||||
input: Fingerprint,
|
||||
history: AugurEntry[],
|
||||
excludeId?: string
|
||||
): OracleMatchSet {
|
||||
const matches: AugurEntry[] = [];
|
||||
for (const e of history) {
|
||||
if (e.id === excludeId) continue;
|
||||
if (!isScored(e)) continue;
|
||||
const fp = fingerprint(e);
|
||||
if (!fp) continue;
|
||||
if (matchScore(input, fp) >= LIVING_ORACLE_MIN_SCORE) matches.push(e);
|
||||
}
|
||||
let weighted = 0;
|
||||
let fulfilled = 0;
|
||||
let partly = 0;
|
||||
let notFulfilled = 0;
|
||||
for (const m of matches) {
|
||||
const v = outcomeValue(m.outcome) ?? 0;
|
||||
weighted += v;
|
||||
if (m.outcome === 'fulfilled') fulfilled++;
|
||||
else if (m.outcome === 'partly') partly++;
|
||||
else if (m.outcome === 'not-fulfilled') notFulfilled++;
|
||||
}
|
||||
return {
|
||||
matches,
|
||||
n: matches.length,
|
||||
hitRate: matches.length > 0 ? weighted / matches.length : 0,
|
||||
fulfilled,
|
||||
partly,
|
||||
notFulfilled,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether the engine should speak at all, given the history size
|
||||
* and the match-set. Below the cold-start threshold or below the min
|
||||
* match count → silent.
|
||||
*/
|
||||
export function shouldSpeak(historyTotal: number, set: OracleMatchSet): boolean {
|
||||
if (historyTotal < LIVING_ORACLE_COLD_START_MIN) return false;
|
||||
return set.n >= LIVING_ORACLE_MIN_MATCHES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a nuechterner deterministic reflection. No LLM, no hallucinations.
|
||||
* Returns null when shouldSpeak is false. The string is what gets stored
|
||||
* into `livingOracleSnapshot` for audit at resolve-time.
|
||||
*/
|
||||
export function makeReflection(set: OracleMatchSet): string | null {
|
||||
if (set.n < LIVING_ORACLE_MIN_MATCHES) return null;
|
||||
const pct = Math.round(set.hitRate * 100);
|
||||
const parts: string[] = [];
|
||||
parts.push(`Du hast ${set.n} aehnliche Zeichen schon einmal protokolliert.`);
|
||||
const breakdown: string[] = [];
|
||||
if (set.fulfilled) breakdown.push(`${set.fulfilled} eingetreten`);
|
||||
if (set.partly) breakdown.push(`${set.partly} teilweise`);
|
||||
if (set.notFulfilled) breakdown.push(`${set.notFulfilled} nicht eingetreten`);
|
||||
if (breakdown.length > 0) parts.push(`Davon: ${breakdown.join(', ')}.`);
|
||||
parts.push(`Trefferquote bei aehnlichen Mustern: ${pct}%.`);
|
||||
return parts.join(' ');
|
||||
}
|
||||
71
apps/mana/apps/web/src/lib/modules/augur/lib/reminders.ts
Normal file
71
apps/mana/apps/web/src/lib/modules/augur/lib/reminders.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* Augur — Resolve-Reminder Helpers
|
||||
*
|
||||
* Pure date math + due-detection logic. No I/O, no Dexie. Lives under
|
||||
* `lib/` so it can be reused by both the witness UI and (later) the
|
||||
* mana-notify pipeline without dragging Svelte runes along.
|
||||
*
|
||||
* Strategy (docs/plans/augur-module.md M3):
|
||||
* - When the user set `expectedBy`, the entry is "due" the day after
|
||||
* that deadline passed (and outcome === 'open').
|
||||
* - When `expectedBy` is null, fall back to encounteredAt + 30 days.
|
||||
*
|
||||
* The 30-day fallback only applies *for surfacing*, never as data.
|
||||
* `expectedBy` itself stays null on the row — the user can still set
|
||||
* one explicitly later, and we don't want to retroactively claim a
|
||||
* date the user didn't choose.
|
||||
*/
|
||||
|
||||
import type { AugurEntry } from '../types';
|
||||
|
||||
export const DEFAULT_REMINDER_DAYS = 30;
|
||||
|
||||
function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function addDays(isoDate: string, days: number): string {
|
||||
const d = new Date(isoDate);
|
||||
d.setUTCDate(d.getUTCDate() + days);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** ISO date when the entry should first surface as "due", or null if it
|
||||
* never should (already resolved, or invalid encounteredAt). */
|
||||
export function reminderDate(
|
||||
entry: Pick<AugurEntry, 'expectedBy' | 'encounteredAt' | 'outcome'>
|
||||
): string | null {
|
||||
if (entry.outcome !== 'open') return null;
|
||||
if (entry.expectedBy) return entry.expectedBy;
|
||||
if (!entry.encounteredAt) return null;
|
||||
return addDays(entry.encounteredAt, DEFAULT_REMINDER_DAYS);
|
||||
}
|
||||
|
||||
/** True if the entry's reminder date is on or before `today`. */
|
||||
export function isDue(
|
||||
entry: Pick<AugurEntry, 'expectedBy' | 'encounteredAt' | 'outcome'>,
|
||||
today: string = todayIso()
|
||||
): boolean {
|
||||
const r = reminderDate(entry);
|
||||
return r != null && r <= today;
|
||||
}
|
||||
|
||||
/** Days remaining until the reminder fires. Negative if overdue. */
|
||||
export function daysUntilDue(
|
||||
entry: Pick<AugurEntry, 'expectedBy' | 'encounteredAt' | 'outcome'>,
|
||||
today: string = todayIso()
|
||||
): number | null {
|
||||
const r = reminderDate(entry);
|
||||
if (!r) return null;
|
||||
const a = new Date(today).getTime();
|
||||
const b = new Date(r).getTime();
|
||||
return Math.round((b - a) / 86_400_000);
|
||||
}
|
||||
|
||||
/** Filter helper: only entries that are open AND past their reminder date. */
|
||||
export function filterDue<T extends Pick<AugurEntry, 'expectedBy' | 'encounteredAt' | 'outcome'>>(
|
||||
entries: T[],
|
||||
today: string = todayIso()
|
||||
): T[] {
|
||||
return entries.filter((e) => isDue(e, today));
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* Augur — Cross-Module Signal Bridge
|
||||
*
|
||||
* Reads the plaintext daily aggregates from `mood` and `sleep` for the
|
||||
* correlation engine. Both modules keep `level` / `quality` /
|
||||
* `durationMin` plaintext (only `notes` / `withWhom` are encrypted), so
|
||||
* we can build per-date maps without touching the vault.
|
||||
*
|
||||
* Returns reactive maps inside a Svelte runes wrapper.
|
||||
*/
|
||||
|
||||
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
|
||||
import { scopedForModule } from '$lib/data/scope';
|
||||
import type { LocalMoodEntry } from '$lib/modules/mood/types';
|
||||
import type { LocalSleepEntry } from '$lib/modules/sleep/types';
|
||||
import type { MoodByDate, SleepByDate, SleepDay } from './correlation-engine';
|
||||
|
||||
/** Per-date mean mood level. Multiple check-ins on the same day get
|
||||
* averaged because a single number is the right granularity for the
|
||||
* correlation engine's "what was the mood window" question. */
|
||||
export function useMoodByDate() {
|
||||
return useScopedLiveQuery(async () => {
|
||||
const rows = await scopedForModule<LocalMoodEntry, string>('mood', 'moodEntries').toArray();
|
||||
const visible = rows.filter((r) => !r.deletedAt && r.date);
|
||||
const sums = new Map<string, { sum: number; count: number }>();
|
||||
for (const r of visible) {
|
||||
const lvl = Number(r.level);
|
||||
if (!Number.isFinite(lvl)) continue;
|
||||
const cur = sums.get(r.date) ?? { sum: 0, count: 0 };
|
||||
cur.sum += lvl;
|
||||
cur.count++;
|
||||
sums.set(r.date, cur);
|
||||
}
|
||||
const map: MoodByDate = new Map();
|
||||
for (const [date, { sum, count }] of sums) map.set(date, sum / count);
|
||||
return map;
|
||||
}, new Map() as MoodByDate);
|
||||
}
|
||||
|
||||
/** Per-night sleep — one row per date by the sleep module's contract. */
|
||||
export function useSleepByDate() {
|
||||
return useScopedLiveQuery(async () => {
|
||||
const rows = await scopedForModule<LocalSleepEntry, string>('sleep', 'sleepEntries').toArray();
|
||||
const visible = rows.filter((r) => !r.deletedAt && r.date);
|
||||
const map: SleepByDate = new Map();
|
||||
for (const r of visible) {
|
||||
const quality = Number(r.quality);
|
||||
const durationMin = Number(r.durationMin);
|
||||
if (!Number.isFinite(quality) || !Number.isFinite(durationMin)) continue;
|
||||
// If multiple rows exist for the same date (rare — usually one per
|
||||
// night), keep the last write — sleep entries are unique per date
|
||||
// in practice but the contract doesn't enforce it.
|
||||
map.set(r.date, { quality, durationMin } satisfies SleepDay);
|
||||
}
|
||||
return map;
|
||||
}, new Map() as SleepByDate);
|
||||
}
|
||||
111
apps/mana/apps/web/src/lib/modules/augur/lib/year-recap.ts
Normal file
111
apps/mana/apps/web/src/lib/modules/augur/lib/year-recap.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
/**
|
||||
* Augur — Year Recap Aggregator
|
||||
*
|
||||
* Pure: takes the full augur entry list + a year and returns a structured
|
||||
* snapshot suitable for both the YearRecapView and the `augur_year_recap`
|
||||
* MCP tool. No I/O.
|
||||
*
|
||||
* The shape is stable so a future LLM-phrasing layer (M6 stretch) can
|
||||
* narrate from it without re-implementing the maths.
|
||||
*/
|
||||
|
||||
import {
|
||||
calibrationPerSource,
|
||||
overallStats,
|
||||
vibeHitRates,
|
||||
type SourceCalibration,
|
||||
type VibeHitRate,
|
||||
} from './calibration';
|
||||
import type { AugurEntry, AugurKind, AugurOutcome, AugurSourceCategory, AugurVibe } from '../types';
|
||||
|
||||
export interface YearRecap {
|
||||
year: number;
|
||||
total: number;
|
||||
resolved: number;
|
||||
open: number;
|
||||
hitRate: number | null;
|
||||
brier: number | null;
|
||||
brierN: number;
|
||||
byKind: Record<AugurKind, number>;
|
||||
byVibe: Record<AugurVibe, number>;
|
||||
byOutcome: Record<AugurOutcome, number>;
|
||||
topCategories: { category: AugurSourceCategory; n: number; hitRate: number }[];
|
||||
bestSource: SourceCalibration | null;
|
||||
worstSource: SourceCalibration | null;
|
||||
vibeRows: VibeHitRate[];
|
||||
mostFulfilled: AugurEntry[];
|
||||
mostSurprising: AugurEntry[];
|
||||
}
|
||||
|
||||
function isInYear(e: AugurEntry, year: number): boolean {
|
||||
return e.encounteredAt.startsWith(`${year}-`);
|
||||
}
|
||||
|
||||
export function buildYearRecap(entries: AugurEntry[], year: number): YearRecap {
|
||||
const inYear = entries.filter((e) => isInYear(e, year));
|
||||
|
||||
const stats = overallStats(inYear);
|
||||
const vibeRows = vibeHitRates(inYear);
|
||||
const sourceRows = calibrationPerSource(inYear);
|
||||
|
||||
const byKind: Record<AugurKind, number> = { omen: 0, fortune: 0, hunch: 0 };
|
||||
const byVibe: Record<AugurVibe, number> = { good: 0, bad: 0, mysterious: 0 };
|
||||
const byOutcome: Record<AugurOutcome, number> = {
|
||||
open: 0,
|
||||
fulfilled: 0,
|
||||
partly: 0,
|
||||
'not-fulfilled': 0,
|
||||
};
|
||||
for (const e of inYear) {
|
||||
byKind[e.kind]++;
|
||||
byVibe[e.vibe]++;
|
||||
byOutcome[e.outcome]++;
|
||||
}
|
||||
|
||||
const topCategories = sourceRows
|
||||
.slice()
|
||||
.sort((a, b) => b.n - a.n)
|
||||
.slice(0, 5)
|
||||
.map((r) => ({ category: r.sourceCategory, n: r.n, hitRate: r.hitRate }));
|
||||
|
||||
const eligible = sourceRows.filter((r) => r.n >= 3);
|
||||
const bestSource =
|
||||
eligible.length > 0 ? [...eligible].sort((a, b) => b.hitRate - a.hitRate)[0] : null;
|
||||
const worstSource =
|
||||
eligible.length > 0 ? [...eligible].sort((a, b) => a.hitRate - b.hitRate)[0] : null;
|
||||
|
||||
const mostFulfilled = inYear
|
||||
.filter((e) => e.outcome === 'fulfilled')
|
||||
.sort((a, b) => (b.resolvedAt ?? '').localeCompare(a.resolvedAt ?? ''))
|
||||
.slice(0, 5);
|
||||
|
||||
// "Surprising" = good vibe → not-fulfilled, OR bad vibe → fulfilled. The
|
||||
// universe disagreed with the user's gut. These tend to be the most
|
||||
// learning-worthy moments at year-end.
|
||||
const mostSurprising = inYear
|
||||
.filter(
|
||||
(e) =>
|
||||
(e.vibe === 'good' && e.outcome === 'not-fulfilled') ||
|
||||
(e.vibe === 'bad' && e.outcome === 'fulfilled')
|
||||
)
|
||||
.slice(0, 5);
|
||||
|
||||
return {
|
||||
year,
|
||||
total: inYear.length,
|
||||
resolved: stats.resolved,
|
||||
open: stats.open,
|
||||
hitRate: stats.hitRate,
|
||||
brier: stats.brier,
|
||||
brierN: stats.brierN,
|
||||
byKind,
|
||||
byVibe,
|
||||
byOutcome,
|
||||
topCategories,
|
||||
bestSource,
|
||||
worstSource,
|
||||
vibeRows,
|
||||
mostFulfilled,
|
||||
mostSurprising,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import type { ModuleConfig } from '$lib/data/module-registry';
|
||||
|
||||
export const augurModuleConfig: ModuleConfig = {
|
||||
appId: 'augur',
|
||||
tables: [{ name: 'augurEntries' }],
|
||||
};
|
||||
120
apps/mana/apps/web/src/lib/modules/augur/queries.ts
Normal file
120
apps/mana/apps/web/src/lib/modules/augur/queries.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
|
||||
import { scopedForModule } from '$lib/data/scope';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { isDue } from './lib/reminders';
|
||||
import type { AugurEntry, AugurKind, LocalAugurEntry } from './types';
|
||||
|
||||
// ─── Type Converter ────────────────────────────────────────
|
||||
|
||||
export function toAugurEntry(local: LocalAugurEntry): AugurEntry {
|
||||
return {
|
||||
id: local.id,
|
||||
kind: local.kind,
|
||||
source: local.source,
|
||||
sourceCategory: local.sourceCategory,
|
||||
claim: local.claim,
|
||||
vibe: local.vibe,
|
||||
feltMeaning: local.feltMeaning ?? null,
|
||||
expectedOutcome: local.expectedOutcome ?? null,
|
||||
expectedBy: local.expectedBy ?? null,
|
||||
probability: local.probability ?? null,
|
||||
outcome: local.outcome,
|
||||
outcomeNote: local.outcomeNote ?? null,
|
||||
resolvedAt: local.resolvedAt ?? null,
|
||||
encounteredAt: local.encounteredAt,
|
||||
tags: local.tags ?? [],
|
||||
relatedDreamId: local.relatedDreamId ?? null,
|
||||
relatedDecisionId: local.relatedDecisionId ?? null,
|
||||
livingOracleSnapshot: local.livingOracleSnapshot ?? null,
|
||||
isPrivate: local.isPrivate,
|
||||
isArchived: local.isArchived,
|
||||
visibility: local.visibility ?? 'private',
|
||||
unlistedToken: local.unlistedToken ?? '',
|
||||
unlistedExpiresAt: local.unlistedExpiresAt ?? null,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Live Queries ──────────────────────────────────────────
|
||||
|
||||
function visibleScoped() {
|
||||
return scopedForModule<LocalAugurEntry, string>('augur', 'augurEntries').toArray();
|
||||
}
|
||||
|
||||
export function useAllAugurEntries() {
|
||||
return useScopedLiveQuery(async () => {
|
||||
const visible = (await visibleScoped()).filter((e) => !e.deletedAt && !e.isArchived);
|
||||
const decrypted = await decryptRecords('augurEntries', visible);
|
||||
return decrypted
|
||||
.map(toAugurEntry)
|
||||
.sort((a, b) => b.encounteredAt.localeCompare(a.encounteredAt));
|
||||
}, [] as AugurEntry[]);
|
||||
}
|
||||
|
||||
export function useAugurEntriesByKind(kind: AugurKind) {
|
||||
return useScopedLiveQuery(async () => {
|
||||
const visible = (await visibleScoped()).filter(
|
||||
(e) => !e.deletedAt && !e.isArchived && e.kind === kind
|
||||
);
|
||||
const decrypted = await decryptRecords('augurEntries', visible);
|
||||
return decrypted
|
||||
.map(toAugurEntry)
|
||||
.sort((a, b) => b.encounteredAt.localeCompare(a.encounteredAt));
|
||||
}, [] as AugurEntry[]);
|
||||
}
|
||||
|
||||
export function useUnresolvedAugurEntries() {
|
||||
return useScopedLiveQuery(async () => {
|
||||
const visible = (await visibleScoped()).filter(
|
||||
(e) => !e.deletedAt && !e.isArchived && e.outcome === 'open'
|
||||
);
|
||||
const decrypted = await decryptRecords('augurEntries', visible);
|
||||
return decrypted
|
||||
.map(toAugurEntry)
|
||||
.sort((a, b) => b.encounteredAt.localeCompare(a.encounteredAt));
|
||||
}, [] as AugurEntry[]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Entries whose reminder date has passed but `outcome` is still 'open'.
|
||||
* Drives the DueBanner (M3) and the inbox card.
|
||||
*
|
||||
* Reminder date = `expectedBy` when set, else `encounteredAt + 30 days`.
|
||||
* Logic centralised in `lib/reminders.ts` so the mana-notify pipeline
|
||||
* later derives the same set without duplicating the rule.
|
||||
*/
|
||||
export function useDueForReveal() {
|
||||
return useScopedLiveQuery(async () => {
|
||||
const visible = (await visibleScoped()).filter(
|
||||
(e) => !e.deletedAt && !e.isArchived && e.outcome === 'open'
|
||||
);
|
||||
const decrypted = await decryptRecords('augurEntries', visible);
|
||||
return decrypted
|
||||
.map(toAugurEntry)
|
||||
.filter((e) => isDue(e))
|
||||
.sort((a, b) =>
|
||||
(a.expectedBy ?? a.encounteredAt).localeCompare(b.expectedBy ?? b.encounteredAt)
|
||||
);
|
||||
}, [] as AugurEntry[]);
|
||||
}
|
||||
|
||||
// ─── Pure Helpers ──────────────────────────────────────────
|
||||
|
||||
export function searchAugurEntries(entries: AugurEntry[], query: string): AugurEntry[] {
|
||||
if (!query.trim()) return entries;
|
||||
const q = query.toLowerCase();
|
||||
return entries.filter((e) => {
|
||||
const haystack = [e.source, e.claim, e.feltMeaning, e.expectedOutcome, e.outcomeNote]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return haystack.includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
export function groupByKind(entries: AugurEntry[]): Record<AugurKind, AugurEntry[]> {
|
||||
const groups: Record<AugurKind, AugurEntry[]> = { omen: [], fortune: [], hunch: [] };
|
||||
for (const e of entries) groups[e.kind].push(e);
|
||||
return groups;
|
||||
}
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
import { augurEntriesTable } from '../collections';
|
||||
import { toAugurEntry } from '../queries';
|
||||
import { encryptRecord } from '$lib/data/crypto';
|
||||
import { generateUnlistedToken, type VisibilityLevel } from '@mana/shared-privacy';
|
||||
import { getEffectiveUserId } from '$lib/data/current-user';
|
||||
import type {
|
||||
AugurEntry,
|
||||
AugurKind,
|
||||
AugurOutcome,
|
||||
AugurSourceCategory,
|
||||
AugurVibe,
|
||||
LocalAugurEntry,
|
||||
} from '../types';
|
||||
|
||||
function todayIsoDate(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export const augurStore = {
|
||||
async createEntry(data: {
|
||||
kind: AugurKind;
|
||||
source: string;
|
||||
sourceCategory: AugurSourceCategory;
|
||||
claim: string;
|
||||
vibe: AugurVibe;
|
||||
feltMeaning?: string | null;
|
||||
expectedOutcome?: string | null;
|
||||
expectedBy?: string | null;
|
||||
probability?: number | null;
|
||||
encounteredAt?: string;
|
||||
tags?: string[];
|
||||
relatedDreamId?: string | null;
|
||||
relatedDecisionId?: string | null;
|
||||
livingOracleSnapshot?: string | null;
|
||||
isPrivate?: boolean;
|
||||
}): Promise<AugurEntry> {
|
||||
const id = crypto.randomUUID();
|
||||
const newLocal: LocalAugurEntry = {
|
||||
id,
|
||||
kind: data.kind,
|
||||
source: data.source,
|
||||
sourceCategory: data.sourceCategory,
|
||||
claim: data.claim,
|
||||
vibe: data.vibe,
|
||||
feltMeaning: data.feltMeaning ?? null,
|
||||
expectedOutcome: data.expectedOutcome ?? null,
|
||||
expectedBy: data.expectedBy ?? null,
|
||||
probability: data.probability ?? null,
|
||||
outcome: 'open',
|
||||
outcomeNote: null,
|
||||
resolvedAt: null,
|
||||
encounteredAt: data.encounteredAt ?? todayIsoDate(),
|
||||
tags: data.tags ?? [],
|
||||
relatedDreamId: data.relatedDreamId ?? null,
|
||||
relatedDecisionId: data.relatedDecisionId ?? null,
|
||||
livingOracleSnapshot: data.livingOracleSnapshot ?? null,
|
||||
isPrivate: data.isPrivate ?? true,
|
||||
isArchived: false,
|
||||
visibility: 'private',
|
||||
};
|
||||
|
||||
const plaintextSnapshot = toAugurEntry(newLocal);
|
||||
await encryptRecord('augurEntries', newLocal);
|
||||
await augurEntriesTable.add(newLocal);
|
||||
return plaintextSnapshot;
|
||||
},
|
||||
|
||||
async updateEntry(
|
||||
id: string,
|
||||
data: Partial<
|
||||
Pick<
|
||||
LocalAugurEntry,
|
||||
| 'source'
|
||||
| 'sourceCategory'
|
||||
| 'claim'
|
||||
| 'vibe'
|
||||
| 'feltMeaning'
|
||||
| 'expectedOutcome'
|
||||
| 'expectedBy'
|
||||
| 'probability'
|
||||
| 'tags'
|
||||
| 'isPrivate'
|
||||
| 'relatedDreamId'
|
||||
| 'relatedDecisionId'
|
||||
>
|
||||
>
|
||||
) {
|
||||
const diff: Partial<LocalAugurEntry> = {
|
||||
...data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await encryptRecord('augurEntries', diff);
|
||||
await augurEntriesTable.update(id, diff);
|
||||
},
|
||||
|
||||
async resolveEntry(id: string, outcome: AugurOutcome, note?: string | null) {
|
||||
const diff: Partial<LocalAugurEntry> = {
|
||||
outcome,
|
||||
outcomeNote: note ?? null,
|
||||
resolvedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await encryptRecord('augurEntries', diff);
|
||||
await augurEntriesTable.update(id, diff);
|
||||
},
|
||||
|
||||
async archiveEntry(id: string) {
|
||||
await augurEntriesTable.update(id, {
|
||||
isArchived: true,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
async deleteEntry(id: string) {
|
||||
await augurEntriesTable.update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Flip the visibility level. M6 wires the local field + token-allocation;
|
||||
* the unlisted-snapshot publish/revoke pipeline (server-side blob store)
|
||||
* is a follow-up — until then, 'unlisted' just allocates a local token so
|
||||
* the share URL is stable when we wire the backend.
|
||||
*/
|
||||
async setVisibility(id: string, next: VisibilityLevel) {
|
||||
const existing = await augurEntriesTable.get(id);
|
||||
if (!existing) return;
|
||||
|
||||
const userId = getEffectiveUserId();
|
||||
const now = new Date().toISOString();
|
||||
const diff: Partial<LocalAugurEntry> = {
|
||||
visibility: next,
|
||||
visibilityChangedAt: now,
|
||||
visibilityChangedBy: userId ?? undefined,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
if (next === 'unlisted') {
|
||||
if (!existing.unlistedToken) diff.unlistedToken = generateUnlistedToken();
|
||||
} else {
|
||||
if (existing.unlistedToken) {
|
||||
diff.unlistedToken = undefined;
|
||||
diff.unlistedExpiresAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
await augurEntriesTable.update(id, diff);
|
||||
},
|
||||
};
|
||||
312
apps/mana/apps/web/src/lib/modules/augur/tools.ts
Normal file
312
apps/mana/apps/web/src/lib/modules/augur/tools.ts
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
/**
|
||||
* Augur tools — AI-accessible CRUD + Living-Oracle consultation.
|
||||
*
|
||||
* Propose:
|
||||
* - capture_sign — create a new omen / fortune / hunch
|
||||
* - resolve_sign — mark an open sign as fulfilled / partly / not-fulfilled
|
||||
*
|
||||
* Auto:
|
||||
* - list_open_signs — what's still waiting on resolution
|
||||
* - consult_oracle — the Living Oracle (deterministic, cold-start gated)
|
||||
* - augur_year_recap — structured year-in-review snapshot
|
||||
*/
|
||||
|
||||
import type { ModuleTool } from '$lib/data/tools/types';
|
||||
import { augurStore } from './stores/entries.svelte';
|
||||
import { db } from '$lib/data/database';
|
||||
import { decryptRecords, VaultLockedError } from '$lib/data/crypto';
|
||||
import { toAugurEntry } from './queries';
|
||||
import {
|
||||
findMatches,
|
||||
fingerprint,
|
||||
makeReflection,
|
||||
shouldSpeak,
|
||||
LIVING_ORACLE_COLD_START_MIN,
|
||||
LIVING_ORACLE_MIN_MATCHES,
|
||||
} from './lib/living-oracle';
|
||||
import { buildYearRecap } from './lib/year-recap';
|
||||
import type {
|
||||
AugurKind,
|
||||
AugurOutcome,
|
||||
AugurSourceCategory,
|
||||
AugurVibe,
|
||||
LocalAugurEntry,
|
||||
} from './types';
|
||||
|
||||
const KINDS: readonly AugurKind[] = ['omen', 'fortune', 'hunch'];
|
||||
const VIBES: readonly AugurVibe[] = ['good', 'bad', 'mysterious'];
|
||||
const SOURCE_CATEGORIES: readonly AugurSourceCategory[] = [
|
||||
'gut',
|
||||
'tarot',
|
||||
'horoscope',
|
||||
'fortune-cookie',
|
||||
'iching',
|
||||
'dream',
|
||||
'person',
|
||||
'media',
|
||||
'natural',
|
||||
'other',
|
||||
];
|
||||
const RESOLVE_OUTCOMES: readonly Exclude<AugurOutcome, 'open'>[] = [
|
||||
'fulfilled',
|
||||
'partly',
|
||||
'not-fulfilled',
|
||||
];
|
||||
|
||||
function splitList(raw: unknown): string[] | undefined {
|
||||
if (typeof raw !== 'string') return undefined;
|
||||
const parts = raw
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
return parts.length > 0 ? parts : undefined;
|
||||
}
|
||||
|
||||
async function loadAllEntries() {
|
||||
const all = await db.table<LocalAugurEntry>('augurEntries').toArray();
|
||||
const visible = all.filter((e) => !e.deletedAt);
|
||||
const decrypted = await decryptRecords('augurEntries', visible);
|
||||
return decrypted.map(toAugurEntry);
|
||||
}
|
||||
|
||||
export const augurTools: ModuleTool[] = [
|
||||
{
|
||||
name: 'capture_sign',
|
||||
module: 'augur',
|
||||
description: 'Erfasst ein Zeichen (Omen, Wahrsagung oder Bauchgefuehl)',
|
||||
parameters: [
|
||||
{ name: 'kind', type: 'string', description: 'Art', required: true },
|
||||
{ name: 'source', type: 'string', description: 'Quelle', required: true },
|
||||
{ name: 'claim', type: 'string', description: 'Aussage', required: true },
|
||||
{ name: 'sourceCategory', type: 'string', description: 'Quellenkategorie', required: false },
|
||||
{ name: 'vibe', type: 'string', description: 'Stimmung', required: false },
|
||||
{ name: 'feltMeaning', type: 'string', description: 'Eigene Deutung', required: false },
|
||||
{
|
||||
name: 'expectedOutcome',
|
||||
type: 'string',
|
||||
description: 'Konkrete Prognose',
|
||||
required: false,
|
||||
},
|
||||
{ name: 'expectedBy', type: 'string', description: 'Bis wann (YYYY-MM-DD)', required: false },
|
||||
{ name: 'probability', type: 'number', description: '0..1', required: false },
|
||||
{ name: 'tags', type: 'string', description: 'Tags CSV', required: false },
|
||||
],
|
||||
async execute(params) {
|
||||
const kind = params.kind as AugurKind;
|
||||
if (!KINDS.includes(kind)) return { success: false, message: `Unbekannte Art: ${kind}` };
|
||||
|
||||
const source = String(params.source ?? '').trim();
|
||||
if (!source) return { success: false, message: 'source darf nicht leer sein' };
|
||||
|
||||
const claim = String(params.claim ?? '').trim();
|
||||
if (!claim) return { success: false, message: 'claim darf nicht leer sein' };
|
||||
|
||||
const vibe = (params.vibe as AugurVibe | undefined) ?? 'mysterious';
|
||||
if (!VIBES.includes(vibe)) return { success: false, message: `Unbekannte Stimmung: ${vibe}` };
|
||||
|
||||
const sourceCategory = (params.sourceCategory as AugurSourceCategory | undefined) ?? 'other';
|
||||
if (!SOURCE_CATEGORIES.includes(sourceCategory)) {
|
||||
return { success: false, message: `Unbekannte Quellenkategorie: ${sourceCategory}` };
|
||||
}
|
||||
|
||||
const probability = typeof params.probability === 'number' ? params.probability : null;
|
||||
if (probability !== null && (probability < 0 || probability > 1)) {
|
||||
return { success: false, message: 'probability muss zwischen 0 und 1 liegen' };
|
||||
}
|
||||
|
||||
const entry = await augurStore.createEntry({
|
||||
kind,
|
||||
source,
|
||||
sourceCategory,
|
||||
claim,
|
||||
vibe,
|
||||
feltMeaning: typeof params.feltMeaning === 'string' ? params.feltMeaning : null,
|
||||
expectedOutcome: typeof params.expectedOutcome === 'string' ? params.expectedOutcome : null,
|
||||
expectedBy: typeof params.expectedBy === 'string' ? params.expectedBy : null,
|
||||
probability,
|
||||
tags: splitList(params.tags),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { entryId: entry.id, kind: entry.kind, source: entry.source },
|
||||
message: `${entry.kind} erfasst: "${entry.source}"`,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'resolve_sign',
|
||||
module: 'augur',
|
||||
description: 'Loest ein offenes Zeichen auf',
|
||||
parameters: [
|
||||
{ name: 'entryId', type: 'string', description: 'ID', required: true },
|
||||
{ name: 'outcome', type: 'string', description: 'Ergebnis', required: true },
|
||||
{ name: 'note', type: 'string', description: 'Optionale Notiz', required: false },
|
||||
],
|
||||
async execute(params) {
|
||||
const entryId = String(params.entryId ?? '');
|
||||
const outcome = params.outcome as AugurOutcome;
|
||||
if (!RESOLVE_OUTCOMES.includes(outcome as Exclude<AugurOutcome, 'open'>)) {
|
||||
return { success: false, message: `Unbekanntes Ergebnis: ${outcome}` };
|
||||
}
|
||||
const existing = await db.table<LocalAugurEntry>('augurEntries').get(entryId);
|
||||
if (!existing || existing.deletedAt) {
|
||||
return { success: false, message: `Eintrag ${entryId} nicht gefunden` };
|
||||
}
|
||||
await augurStore.resolveEntry(
|
||||
entryId,
|
||||
outcome,
|
||||
typeof params.note === 'string' ? params.note : null
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
data: { entryId, outcome },
|
||||
message: `Zeichen aufgeloest: ${outcome}`,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'list_open_signs',
|
||||
module: 'augur',
|
||||
description: 'Listet noch offene Zeichen',
|
||||
parameters: [
|
||||
{ name: 'kind', type: 'string', description: 'Filter nach Art', required: false },
|
||||
{ name: 'limit', type: 'number', description: 'Max (Standard 30)', required: false },
|
||||
],
|
||||
async execute(params) {
|
||||
const kindFilter = params.kind as AugurKind | undefined;
|
||||
if (kindFilter !== undefined && !KINDS.includes(kindFilter)) {
|
||||
return { success: false, message: `Unbekannte Art: ${kindFilter}` };
|
||||
}
|
||||
const limit = Math.min(Math.max(Number(params.limit) || 30, 1), 100);
|
||||
try {
|
||||
const entries = await loadAllEntries();
|
||||
const rows = entries
|
||||
.filter((e) => !e.isArchived && e.outcome === 'open')
|
||||
.filter((e) => (kindFilter ? e.kind === kindFilter : true))
|
||||
.sort((a, b) =>
|
||||
(a.expectedBy ?? a.encounteredAt).localeCompare(b.expectedBy ?? b.encounteredAt)
|
||||
)
|
||||
.slice(0, limit)
|
||||
.map((e) => ({
|
||||
id: e.id,
|
||||
kind: e.kind,
|
||||
source: e.source,
|
||||
claim: e.claim,
|
||||
vibe: e.vibe,
|
||||
encounteredAt: e.encounteredAt,
|
||||
expectedBy: e.expectedBy,
|
||||
}));
|
||||
return {
|
||||
success: true,
|
||||
data: { entries: rows, total: rows.length },
|
||||
message: `${rows.length} offene(s) Zeichen`,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof VaultLockedError) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Vault ist gesperrt — Augur kann nicht entschluesselt werden',
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'consult_oracle',
|
||||
module: 'augur',
|
||||
description: 'Befragt das Living Oracle',
|
||||
parameters: [
|
||||
{ name: 'kind', type: 'string', description: 'Art', required: true },
|
||||
{ name: 'sourceCategory', type: 'string', description: 'Kategorie', required: true },
|
||||
{ name: 'vibe', type: 'string', description: 'Stimmung', required: true },
|
||||
{ name: 'source', type: 'string', description: 'Quellen-Stichwort', required: false },
|
||||
{ name: 'claim', type: 'string', description: 'Aussage', required: false },
|
||||
{ name: 'tags', type: 'string', description: 'Tags CSV', required: false },
|
||||
],
|
||||
async execute(params) {
|
||||
const kind = params.kind as AugurKind;
|
||||
if (!KINDS.includes(kind)) return { success: false, message: `Unbekannte Art: ${kind}` };
|
||||
const sourceCategory = params.sourceCategory as AugurSourceCategory;
|
||||
if (!SOURCE_CATEGORIES.includes(sourceCategory)) {
|
||||
return { success: false, message: `Unbekannte Quellenkategorie: ${sourceCategory}` };
|
||||
}
|
||||
const vibe = params.vibe as AugurVibe;
|
||||
if (!VIBES.includes(vibe)) return { success: false, message: `Unbekannte Stimmung: ${vibe}` };
|
||||
|
||||
try {
|
||||
const history = await loadAllEntries();
|
||||
const fp = fingerprint({
|
||||
kind,
|
||||
sourceCategory,
|
||||
vibe,
|
||||
tags: splitList(params.tags),
|
||||
source: typeof params.source === 'string' ? params.source : null,
|
||||
claim: typeof params.claim === 'string' ? params.claim : null,
|
||||
});
|
||||
if (!fp) return { success: false, message: 'Fingerprint konnte nicht gebildet werden' };
|
||||
|
||||
const set = findMatches(fp, history);
|
||||
const speaks = shouldSpeak(history.length, set);
|
||||
const reflection = speaks ? makeReflection(set) : null;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
speaks,
|
||||
reflection,
|
||||
matches: set.n,
|
||||
hitRate: set.hitRate,
|
||||
breakdown: {
|
||||
fulfilled: set.fulfilled,
|
||||
partly: set.partly,
|
||||
notFulfilled: set.notFulfilled,
|
||||
},
|
||||
thresholds: {
|
||||
coldStart: LIVING_ORACLE_COLD_START_MIN,
|
||||
minMatches: LIVING_ORACLE_MIN_MATCHES,
|
||||
historyTotal: history.length,
|
||||
},
|
||||
},
|
||||
message: reflection ?? 'Orakel schweigt — noch zu wenig aehnliche Daten.',
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof VaultLockedError) {
|
||||
return { success: false, message: 'Vault ist gesperrt' };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'augur_year_recap',
|
||||
module: 'augur',
|
||||
description: 'Strukturierter Jahresrueckblick',
|
||||
parameters: [
|
||||
{
|
||||
name: 'year',
|
||||
type: 'number',
|
||||
description: 'YYYY (default: aktuelles Jahr)',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
async execute(params) {
|
||||
const year = Number(params.year) || new Date().getFullYear();
|
||||
try {
|
||||
const entries = await loadAllEntries();
|
||||
const recap = buildYearRecap(entries, year);
|
||||
return {
|
||||
success: true,
|
||||
data: recap,
|
||||
message: `Jahresrueckblick ${year}: ${recap.total} Zeichen, ${recap.resolved} aufgeloest`,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof VaultLockedError) {
|
||||
return { success: false, message: 'Vault ist gesperrt' };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
133
apps/mana/apps/web/src/lib/modules/augur/types.ts
Normal file
133
apps/mana/apps/web/src/lib/modules/augur/types.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import type { BaseRecord } from '@mana/local-store';
|
||||
import type { VisibilityLevel } from '@mana/shared-privacy';
|
||||
|
||||
// ─── Enums ────────────────────────────────────────────────
|
||||
|
||||
export type AugurKind = 'omen' | 'fortune' | 'hunch';
|
||||
|
||||
export type AugurVibe = 'good' | 'bad' | 'mysterious';
|
||||
|
||||
export type AugurOutcome = 'open' | 'fulfilled' | 'partly' | 'not-fulfilled';
|
||||
|
||||
/**
|
||||
* Coarse category of the source — used for "Calibration per Source" in
|
||||
* the Oracle view. Free-form `source` (e.g. "Mutter", "schwarze Katze")
|
||||
* stays encrypted; the category stays plaintext for the aggregation
|
||||
* query path.
|
||||
*/
|
||||
export type AugurSourceCategory =
|
||||
| 'gut'
|
||||
| 'tarot'
|
||||
| 'horoscope'
|
||||
| 'fortune-cookie'
|
||||
| 'iching'
|
||||
| 'dream'
|
||||
| 'person'
|
||||
| 'media'
|
||||
| 'natural'
|
||||
| 'other';
|
||||
|
||||
// ─── Local Record Types (Dexie) ───────────────────────────
|
||||
|
||||
export interface LocalAugurEntry extends BaseRecord {
|
||||
kind: AugurKind;
|
||||
source: string;
|
||||
sourceCategory: AugurSourceCategory;
|
||||
claim: string;
|
||||
vibe: AugurVibe;
|
||||
feltMeaning: string | null;
|
||||
expectedOutcome: string | null;
|
||||
expectedBy: string | null;
|
||||
probability: number | null;
|
||||
outcome: AugurOutcome;
|
||||
outcomeNote: string | null;
|
||||
resolvedAt: string | null;
|
||||
encounteredAt: string;
|
||||
tags: string[];
|
||||
relatedDreamId: string | null;
|
||||
relatedDecisionId: string | null;
|
||||
livingOracleSnapshot: string | null;
|
||||
isPrivate: boolean;
|
||||
isArchived: boolean;
|
||||
/**
|
||||
* Visibility level — unified privacy system (docs/plans/visibility-system.md).
|
||||
* Optional on the local record because M1–M5 rows pre-date the field;
|
||||
* `toAugurEntry` narrows to a non-optional VisibilityLevel. Default is
|
||||
* `'private'` because divinatory captures can be very personal.
|
||||
*/
|
||||
visibility?: VisibilityLevel;
|
||||
visibilityChangedAt?: string;
|
||||
visibilityChangedBy?: string;
|
||||
unlistedToken?: string;
|
||||
unlistedExpiresAt?: string | null;
|
||||
}
|
||||
|
||||
// ─── Domain Types ─────────────────────────────────────────
|
||||
|
||||
export interface AugurEntry {
|
||||
id: string;
|
||||
kind: AugurKind;
|
||||
source: string;
|
||||
sourceCategory: AugurSourceCategory;
|
||||
claim: string;
|
||||
vibe: AugurVibe;
|
||||
feltMeaning: string | null;
|
||||
expectedOutcome: string | null;
|
||||
expectedBy: string | null;
|
||||
probability: number | null;
|
||||
outcome: AugurOutcome;
|
||||
outcomeNote: string | null;
|
||||
resolvedAt: string | null;
|
||||
encounteredAt: string;
|
||||
tags: string[];
|
||||
relatedDreamId: string | null;
|
||||
relatedDecisionId: string | null;
|
||||
livingOracleSnapshot: string | null;
|
||||
isPrivate: boolean;
|
||||
isArchived: boolean;
|
||||
visibility: VisibilityLevel;
|
||||
unlistedToken: string;
|
||||
unlistedExpiresAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────
|
||||
|
||||
export const KIND_LABELS: Record<AugurKind, { de: string; en: string }> = {
|
||||
omen: { de: 'Omen', en: 'Omen' },
|
||||
fortune: { de: 'Wahrsagung', en: 'Fortune' },
|
||||
hunch: { de: 'Bauchgefühl', en: 'Hunch' },
|
||||
};
|
||||
|
||||
export const VIBE_LABELS: Record<AugurVibe, { de: string; en: string }> = {
|
||||
good: { de: 'Gutes Zeichen', en: 'Good sign' },
|
||||
bad: { de: 'Warnung', en: 'Warning' },
|
||||
mysterious: { de: 'Rätselhaft', en: 'Mysterious' },
|
||||
};
|
||||
|
||||
export const VIBE_COLORS: Record<AugurVibe, string> = {
|
||||
good: '#22c55e',
|
||||
bad: '#ef4444',
|
||||
mysterious: '#8b5cf6',
|
||||
};
|
||||
|
||||
export const OUTCOME_LABELS: Record<AugurOutcome, { de: string; en: string }> = {
|
||||
open: { de: 'Offen', en: 'Open' },
|
||||
fulfilled: { de: 'Eingetreten', en: 'Fulfilled' },
|
||||
partly: { de: 'Teilweise', en: 'Partly' },
|
||||
'not-fulfilled': { de: 'Nicht eingetreten', en: 'Not fulfilled' },
|
||||
};
|
||||
|
||||
export const SOURCE_CATEGORY_LABELS: Record<AugurSourceCategory, { de: string; en: string }> = {
|
||||
gut: { de: 'Bauchgefühl', en: 'Gut feeling' },
|
||||
tarot: { de: 'Tarot', en: 'Tarot' },
|
||||
horoscope: { de: 'Horoskop', en: 'Horoscope' },
|
||||
'fortune-cookie': { de: 'Glückskeks', en: 'Fortune cookie' },
|
||||
iching: { de: 'I Ging', en: 'I Ching' },
|
||||
dream: { de: 'Traum', en: 'Dream' },
|
||||
person: { de: 'Person', en: 'Person' },
|
||||
media: { de: 'Medium', en: 'Media' },
|
||||
natural: { de: 'Naturzeichen', en: 'Natural sign' },
|
||||
other: { de: 'Sonstiges', en: 'Other' },
|
||||
};
|
||||
377
apps/mana/apps/web/src/lib/modules/augur/views/DetailView.svelte
Normal file
377
apps/mana/apps/web/src/lib/modules/augur/views/DetailView.svelte
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
<!--
|
||||
Augur — Detail View
|
||||
|
||||
Single-entry view. Two modes:
|
||||
- Read: shows source, claim, felt meaning, prediction, resolve status.
|
||||
"Hat sich das bewahrheitet?" buttons drive the resolve action.
|
||||
- Edit: full EntryForm with the entry seeded as initial values.
|
||||
|
||||
Strings live in `T` (interpolation pattern). Real $_('augur.*') keys
|
||||
land in M2-i18n.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import EntryForm from '../components/EntryForm.svelte';
|
||||
import VibeBadge from '../components/VibeBadge.svelte';
|
||||
import OutcomeBadge from '../components/OutcomeBadge.svelte';
|
||||
import LivingOracleHint from '../components/LivingOracleHint.svelte';
|
||||
import { augurStore } from '../stores/entries.svelte';
|
||||
import {
|
||||
VisibilityPicker,
|
||||
VISIBILITY_METADATA,
|
||||
type VisibilityLevel,
|
||||
} from '@mana/shared-privacy';
|
||||
import {
|
||||
KIND_LABELS,
|
||||
SOURCE_CATEGORY_LABELS,
|
||||
VIBE_COLORS,
|
||||
type AugurEntry,
|
||||
type AugurOutcome,
|
||||
} from '../types';
|
||||
|
||||
let { entry }: { entry: AugurEntry } = $props();
|
||||
|
||||
const T = {
|
||||
source: 'Quelle',
|
||||
claim: 'Aussage',
|
||||
felt: 'Eigene Deutung',
|
||||
expected: 'Erwartetes Ergebnis',
|
||||
expectedBy: 'Bis',
|
||||
probability: 'Wahrscheinlichkeit',
|
||||
tags: 'Tags',
|
||||
captured: 'Erfasst',
|
||||
resolved: 'Aufgeloest',
|
||||
outcomeNote: 'Wie es kam',
|
||||
resolvePrompt: 'Hat sich das bewahrheitet?',
|
||||
resolveYes: 'eingetreten',
|
||||
resolvePartly: 'teilweise',
|
||||
resolveNo: 'nicht eingetreten',
|
||||
resolveReopen: 'erneut oeffnen',
|
||||
actionEdit: 'bearbeiten',
|
||||
actionArchive: 'archivieren',
|
||||
actionDelete: 'loeschen',
|
||||
actionCancel: 'abbrechen',
|
||||
notePlaceholder: 'Optionale Notiz: wie genau ist es gekommen?',
|
||||
confirmDelete: 'Diesen Eintrag wirklich loeschen?',
|
||||
visibility: 'Sichtbarkeit',
|
||||
} as const;
|
||||
|
||||
async function onVisibilityChange(next: VisibilityLevel) {
|
||||
await augurStore.setVisibility(entry.id, next);
|
||||
}
|
||||
|
||||
let mode = $state<'view' | 'edit'>('view');
|
||||
let resolveNoteOpen = $state(false);
|
||||
let resolveNote = $state('');
|
||||
let pendingOutcome = $state<AugurOutcome | null>(null);
|
||||
|
||||
function startResolve(outcome: AugurOutcome) {
|
||||
pendingOutcome = outcome;
|
||||
resolveNote = '';
|
||||
resolveNoteOpen = true;
|
||||
}
|
||||
|
||||
async function confirmResolve() {
|
||||
if (!pendingOutcome) return;
|
||||
await augurStore.resolveEntry(entry.id, pendingOutcome, resolveNote.trim() || null);
|
||||
pendingOutcome = null;
|
||||
resolveNoteOpen = false;
|
||||
resolveNote = '';
|
||||
}
|
||||
|
||||
async function reopen() {
|
||||
await augurStore.resolveEntry(entry.id, 'open', null);
|
||||
}
|
||||
|
||||
async function handleArchive() {
|
||||
await augurStore.archiveEntry(entry.id);
|
||||
goto('/augur');
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm(T.confirmDelete)) return;
|
||||
await augurStore.deleteEntry(entry.id);
|
||||
goto('/augur');
|
||||
}
|
||||
|
||||
const sourceCategoryLabel = $derived(SOURCE_CATEGORY_LABELS[entry.sourceCategory].de);
|
||||
</script>
|
||||
|
||||
{#if mode === 'edit'}
|
||||
<EntryForm mode="edit" initial={entry} onclose={() => (mode = 'view')} />
|
||||
{:else}
|
||||
<article class="detail" style:--vibe-color={VIBE_COLORS[entry.vibe]}>
|
||||
<header class="head">
|
||||
<div class="head-row">
|
||||
<span class="kind">{KIND_LABELS[entry.kind].de}</span>
|
||||
<span class="dot">·</span>
|
||||
<span class="cat">{sourceCategoryLabel}</span>
|
||||
<span class="dot">·</span>
|
||||
<span class="date">{entry.encounteredAt}</span>
|
||||
</div>
|
||||
<h2 class="source">{entry.source}</h2>
|
||||
<p class="claim">{entry.claim}</p>
|
||||
<div class="badges">
|
||||
<VibeBadge vibe={entry.vibe} size="md" />
|
||||
<OutcomeBadge outcome={entry.outcome} size="md" />
|
||||
{#if entry.probability != null}
|
||||
<span class="prob">{Math.round(entry.probability * 100)}%</span>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if entry.feltMeaning}
|
||||
<section class="block">
|
||||
<h3>{T.felt}</h3>
|
||||
<p>{entry.feltMeaning}</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if entry.expectedOutcome || entry.expectedBy}
|
||||
<section class="block">
|
||||
<h3>{T.expected}</h3>
|
||||
{#if entry.expectedOutcome}<p>{entry.expectedOutcome}</p>{/if}
|
||||
{#if entry.expectedBy}
|
||||
<p class="meta">{T.expectedBy}: {entry.expectedBy}</p>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if entry.tags.length > 0}
|
||||
<section class="block">
|
||||
<h3>{T.tags}</h3>
|
||||
<div class="tags">
|
||||
{#each entry.tags as tag (tag)}
|
||||
<span class="tag">{tag}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if entry.livingOracleSnapshot}
|
||||
<LivingOracleHint mode="snapshot" snapshot={entry.livingOracleSnapshot} />
|
||||
{/if}
|
||||
|
||||
<section class="block">
|
||||
<h3>{T.visibility}</h3>
|
||||
<VisibilityPicker level={entry.visibility} onChange={onVisibilityChange} />
|
||||
</section>
|
||||
|
||||
<section class="block resolve">
|
||||
{#if entry.outcome === 'open' && !resolveNoteOpen}
|
||||
<h3>{T.resolvePrompt}</h3>
|
||||
<div class="resolve-row">
|
||||
<button type="button" class="btn yes" onclick={() => startResolve('fulfilled')}>
|
||||
{T.resolveYes}
|
||||
</button>
|
||||
<button type="button" class="btn partly" onclick={() => startResolve('partly')}>
|
||||
{T.resolvePartly}
|
||||
</button>
|
||||
<button type="button" class="btn no" onclick={() => startResolve('not-fulfilled')}>
|
||||
{T.resolveNo}
|
||||
</button>
|
||||
</div>
|
||||
{:else if resolveNoteOpen}
|
||||
<h3>{T.outcomeNote}</h3>
|
||||
<textarea bind:value={resolveNote} placeholder={T.notePlaceholder} rows="3"></textarea>
|
||||
<div class="resolve-row">
|
||||
<button type="button" class="btn ghost" onclick={() => (resolveNoteOpen = false)}>
|
||||
{T.actionCancel}
|
||||
</button>
|
||||
<button type="button" class="btn primary" onclick={confirmResolve}>
|
||||
{T.captured}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<h3>{T.resolved}</h3>
|
||||
{#if entry.outcomeNote}
|
||||
<p>{entry.outcomeNote}</p>
|
||||
{/if}
|
||||
{#if entry.resolvedAt}
|
||||
<p class="meta">{entry.resolvedAt.slice(0, 10)}</p>
|
||||
{/if}
|
||||
<div class="resolve-row">
|
||||
<button type="button" class="btn ghost" onclick={reopen}>{T.resolveReopen}</button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<footer class="actions">
|
||||
<button type="button" class="btn ghost" onclick={() => (mode = 'edit')}>
|
||||
{T.actionEdit}
|
||||
</button>
|
||||
<button type="button" class="btn ghost" onclick={handleArchive}>
|
||||
{T.actionArchive}
|
||||
</button>
|
||||
<button type="button" class="btn danger" onclick={handleDelete}>
|
||||
{T.actionDelete}
|
||||
</button>
|
||||
</footer>
|
||||
</article>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
padding: 1.25rem;
|
||||
max-width: 48rem;
|
||||
margin: 0 auto;
|
||||
background: var(--color-surface, rgba(255, 255, 255, 0.03));
|
||||
border-radius: 1rem;
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.07));
|
||||
border-left: 5px solid var(--vibe-color);
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.head-row {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.kind {
|
||||
color: var(--vibe-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
.dot {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.source {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
margin: 0.1rem 0 0;
|
||||
color: var(--color-text, inherit);
|
||||
}
|
||||
.claim {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: var(--color-text, inherit);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.badges {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.prob {
|
||||
font-size: 0.85rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, #38bdf8 18%, transparent);
|
||||
color: #7dd3fc;
|
||||
}
|
||||
.block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.block h3 {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
|
||||
margin: 0;
|
||||
}
|
||||
.block p {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text, inherit);
|
||||
}
|
||||
.block .meta {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
|
||||
}
|
||||
.tags {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tag {
|
||||
font-size: 0.78rem;
|
||||
padding: 0.15rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface-muted, rgba(255, 255, 255, 0.06));
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.7));
|
||||
}
|
||||
.resolve textarea {
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
|
||||
background: var(--color-surface-input, rgba(255, 255, 255, 0.04));
|
||||
color: var(--color-text, inherit);
|
||||
resize: vertical;
|
||||
}
|
||||
.resolve-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--color-border, rgba(255, 255, 255, 0.06));
|
||||
}
|
||||
.btn {
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.55rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.btn.primary {
|
||||
background: color-mix(in srgb, #7c3aed 24%, transparent);
|
||||
border-color: #7c3aed;
|
||||
color: #ddd6fe;
|
||||
}
|
||||
.btn.primary:hover {
|
||||
background: color-mix(in srgb, #7c3aed 32%, transparent);
|
||||
}
|
||||
.btn.yes {
|
||||
background: color-mix(in srgb, #10b981 18%, transparent);
|
||||
border-color: color-mix(in srgb, #10b981 55%, transparent);
|
||||
color: #6ee7b7;
|
||||
}
|
||||
.btn.partly {
|
||||
background: color-mix(in srgb, #f59e0b 18%, transparent);
|
||||
border-color: color-mix(in srgb, #f59e0b 55%, transparent);
|
||||
color: #fcd34d;
|
||||
}
|
||||
.btn.no {
|
||||
background: color-mix(in srgb, #ef4444 18%, transparent);
|
||||
border-color: color-mix(in srgb, #ef4444 55%, transparent);
|
||||
color: #fca5a5;
|
||||
}
|
||||
.btn.ghost {
|
||||
background: transparent;
|
||||
border-color: var(--color-border, rgba(255, 255, 255, 0.12));
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.7));
|
||||
}
|
||||
.btn.ghost:hover {
|
||||
background: var(--color-surface-hover, rgba(255, 255, 255, 0.05));
|
||||
}
|
||||
.btn.danger {
|
||||
background: transparent;
|
||||
border-color: color-mix(in srgb, #ef4444 35%, transparent);
|
||||
color: #fca5a5;
|
||||
}
|
||||
.btn.danger:hover {
|
||||
background: color-mix(in srgb, #ef4444 14%, transparent);
|
||||
}
|
||||
</style>
|
||||
550
apps/mana/apps/web/src/lib/modules/augur/views/OracleView.svelte
Normal file
550
apps/mana/apps/web/src/lib/modules/augur/views/OracleView.svelte
Normal file
|
|
@ -0,0 +1,550 @@
|
|||
<!--
|
||||
Augur — Oracle View
|
||||
|
||||
Reads the same Witness data through an empirical lens. Three sections:
|
||||
|
||||
1. Overall Stats — total / resolved / hit-rate / Brier
|
||||
2. Calibration per Source — ranked table of "Forecaster in your life"
|
||||
3. Vibe-Hit-Rate — did your good/bad vibes track reality?
|
||||
|
||||
Below ORACLE_COLD_START_MIN resolved entries, surfaces a cold-start
|
||||
empty state ("collect first, evaluate later") rather than misleading
|
||||
numbers.
|
||||
|
||||
Correlation Matrix (cross-module mood/sleep mining) is M4-followup —
|
||||
needs the mood/sleep query layer pulled in. Not in this PR.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { useAllAugurEntries } from '../queries';
|
||||
import {
|
||||
calibrationPerSource,
|
||||
vibeHitRates,
|
||||
overallStats,
|
||||
ORACLE_COLD_START_MIN,
|
||||
} from '../lib/calibration';
|
||||
import { computeCorrelations, type CorrelationFinding } from '../lib/correlation-engine';
|
||||
import { useMoodByDate, useSleepByDate } from '../lib/signal-bridge.svelte';
|
||||
import { KIND_LABELS, SOURCE_CATEGORY_LABELS, VIBE_LABELS, VIBE_COLORS } from '../types';
|
||||
|
||||
const T = {
|
||||
title: 'Oracle',
|
||||
subtitle: 'Was deine Daten zurueck sagen.',
|
||||
coldStart: 'Noch zu wenig aufgeloeste Zeichen.',
|
||||
coldStartHint: 'Sammle und loese mindestens',
|
||||
coldStartUnit: 'Zeichen auf, dann zeigt das Orakel verlaessliche Zahlen.',
|
||||
statTotal: 'gesammelt',
|
||||
statResolved: 'aufgeloest',
|
||||
statOpen: 'offen',
|
||||
statHitRate: 'Trefferquote',
|
||||
statBrier: 'Brier-Score',
|
||||
statBrierBaseline: 'Baseline 0.25 (50/50)',
|
||||
sourceTitle: 'Forecaster in deinem Leben',
|
||||
sourceSub: 'Wer / was war wie oft richtig?',
|
||||
sourceCol: 'Quelle',
|
||||
sourceN: 'n',
|
||||
sourceHit: 'Treffer',
|
||||
sourceBrier: 'Brier',
|
||||
sourceMix: 'Verteilung',
|
||||
vibeTitle: 'Stimmt dein Bauchgefuehl?',
|
||||
vibeSub: 'Treffer pro Stimmung — gut, raetselhaft, schlecht.',
|
||||
vibeNoData: 'noch keine Daten',
|
||||
vibeHit: 'Treffer',
|
||||
vibeDir: 'Richtung',
|
||||
brierUnknown: '–',
|
||||
corrTitle: 'Was um deine Zeichen herum passiert',
|
||||
corrSub: 'Korrelation, nicht Kausalitaet — gefunden in deinen eigenen Daten.',
|
||||
corrEmpty:
|
||||
'Noch keine belastbaren Muster. Logge weiter — Mood und Sleep werden dazu gerechnet.',
|
||||
corrAfter: 'In den 3 Tagen danach',
|
||||
corrMoodLevel: 'lag dein Mood-Level bei',
|
||||
corrSleepQuality: 'lag deine Schlaf-Qualitaet bei',
|
||||
corrSleepDuration: 'lag deine Schlafdauer bei',
|
||||
corrVsBaseline: 'Baseline:',
|
||||
corrUnits: { min: 'min', score: '/10', score5: '/5' },
|
||||
yearRecapLink: 'Jahresrueckblick →',
|
||||
} as const;
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const entries$ = useAllAugurEntries();
|
||||
const entries = $derived(entries$.value);
|
||||
|
||||
const moodByDate$ = useMoodByDate();
|
||||
const moodByDate = $derived(moodByDate$.value);
|
||||
const sleepByDate$ = useSleepByDate();
|
||||
const sleepByDate = $derived(sleepByDate$.value);
|
||||
|
||||
const stats = $derived(overallStats(entries));
|
||||
const sourceRows = $derived(calibrationPerSource(entries));
|
||||
const vibeRows = $derived(vibeHitRates(entries));
|
||||
const correlations = $derived(computeCorrelations(entries, moodByDate, sleepByDate));
|
||||
|
||||
function metricLabel(f: CorrelationFinding): string {
|
||||
switch (f.metric) {
|
||||
case 'mood-level':
|
||||
return T.corrMoodLevel;
|
||||
case 'sleep-quality':
|
||||
return T.corrSleepQuality;
|
||||
case 'sleep-duration':
|
||||
return T.corrSleepDuration;
|
||||
}
|
||||
}
|
||||
|
||||
function metricUnit(f: CorrelationFinding): string {
|
||||
switch (f.metric) {
|
||||
case 'mood-level':
|
||||
return T.corrUnits.score;
|
||||
case 'sleep-quality':
|
||||
return T.corrUnits.score5;
|
||||
case 'sleep-duration':
|
||||
return T.corrUnits.min;
|
||||
}
|
||||
}
|
||||
|
||||
function bucketLabel(f: CorrelationFinding): string {
|
||||
if (f.dimension === 'vibe') return VIBE_LABELS[f.bucket as keyof typeof VIBE_LABELS].de;
|
||||
return KIND_LABELS[f.bucket as keyof typeof KIND_LABELS].de;
|
||||
}
|
||||
|
||||
function bucketColor(f: CorrelationFinding): string {
|
||||
if (f.dimension === 'vibe') return VIBE_COLORS[f.bucket as keyof typeof VIBE_COLORS];
|
||||
return '#7c3aed';
|
||||
}
|
||||
|
||||
function fmt(value: number, metric: CorrelationFinding['metric']): string {
|
||||
if (metric === 'sleep-duration') return Math.round(value).toString();
|
||||
return value.toFixed(1);
|
||||
}
|
||||
|
||||
function pct(value: number | null): string {
|
||||
if (value == null) return T.brierUnknown;
|
||||
return `${Math.round(value * 100)}%`;
|
||||
}
|
||||
|
||||
function brier(value: number | null): string {
|
||||
if (value == null) return T.brierUnknown;
|
||||
return value.toFixed(3);
|
||||
}
|
||||
|
||||
const isColdStart = $derived(stats.resolved < ORACLE_COLD_START_MIN);
|
||||
</script>
|
||||
|
||||
<div class="oracle">
|
||||
<header class="head">
|
||||
<div>
|
||||
<h2>{T.title}</h2>
|
||||
<p class="sub">{T.subtitle}</p>
|
||||
</div>
|
||||
<a class="recap-link" href="/augur/recap/{currentYear}">{T.yearRecapLink}</a>
|
||||
</header>
|
||||
|
||||
<section class="stats">
|
||||
<div class="stat">
|
||||
<span class="stat-num">{stats.total}</span>
|
||||
<span class="stat-label">{T.statTotal}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-num">{stats.resolved}</span>
|
||||
<span class="stat-label">{T.statResolved}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-num">{stats.open}</span>
|
||||
<span class="stat-label">{T.statOpen}</span>
|
||||
</div>
|
||||
<div class="stat highlight">
|
||||
<span class="stat-num">{pct(stats.hitRate)}</span>
|
||||
<span class="stat-label">{T.statHitRate}</span>
|
||||
</div>
|
||||
<div class="stat" title={T.statBrierBaseline}>
|
||||
<span class="stat-num">{brier(stats.brier)}</span>
|
||||
<span class="stat-label">{T.statBrier} ({stats.brierN})</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if isColdStart}
|
||||
<section class="cold-start">
|
||||
<p>{T.coldStart}</p>
|
||||
<p class="hint">
|
||||
{T.coldStartHint}
|
||||
<strong>{ORACLE_COLD_START_MIN}</strong>
|
||||
{T.coldStartUnit}
|
||||
</p>
|
||||
<div class="progress">
|
||||
<div
|
||||
class="progress-bar"
|
||||
style:width="{Math.min(100, (stats.resolved / ORACLE_COLD_START_MIN) * 100)}%"
|
||||
></div>
|
||||
</div>
|
||||
</section>
|
||||
{:else}
|
||||
<section class="block">
|
||||
<header class="block-head">
|
||||
<h3>{T.sourceTitle}</h3>
|
||||
<p>{T.sourceSub}</p>
|
||||
</header>
|
||||
{#if sourceRows.length === 0}
|
||||
<p class="empty">{T.vibeNoData}</p>
|
||||
{:else}
|
||||
<table class="ranked">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{T.sourceCol}</th>
|
||||
<th class="num">{T.sourceN}</th>
|
||||
<th class="num">{T.sourceHit}</th>
|
||||
<th class="num">{T.sourceBrier}</th>
|
||||
<th class="mix">{T.sourceMix}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sourceRows as row (row.sourceCategory)}
|
||||
<tr>
|
||||
<td>{SOURCE_CATEGORY_LABELS[row.sourceCategory].de}</td>
|
||||
<td class="num">{row.n}</td>
|
||||
<td class="num bold">{pct(row.hitRate)}</td>
|
||||
<td class="num" title={row.brierN > 0 ? `n=${row.brierN}` : ''}>
|
||||
{brier(row.brier)}
|
||||
</td>
|
||||
<td class="mix">
|
||||
<div class="mix-bar">
|
||||
<span
|
||||
class="seg yes"
|
||||
style:flex={row.fulfilled}
|
||||
title="{row.fulfilled} fulfilled"
|
||||
></span>
|
||||
<span class="seg partly" style:flex={row.partly} title="{row.partly} partly"
|
||||
></span>
|
||||
<span
|
||||
class="seg no"
|
||||
style:flex={row.notFulfilled}
|
||||
title="{row.notFulfilled} not fulfilled"
|
||||
></span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<header class="block-head">
|
||||
<h3>{T.corrTitle}</h3>
|
||||
<p>{T.corrSub}</p>
|
||||
</header>
|
||||
{#if correlations.length === 0}
|
||||
<p class="empty">{T.corrEmpty}</p>
|
||||
{:else}
|
||||
<ul class="corr-list">
|
||||
{#each correlations.slice(0, 6) as f (f.dimension + f.bucket + f.metric + f.windowDays)}
|
||||
<li class="corr" style:--corr-color={bucketColor(f)}>
|
||||
<div class="corr-row">
|
||||
<span class="corr-bucket">{bucketLabel(f)}</span>
|
||||
<span class="corr-arrow">{f.delta >= 0 ? '↑' : '↓'}</span>
|
||||
<span class="corr-delta">
|
||||
{f.delta >= 0 ? '+' : ''}{fmt(f.delta, f.metric)}{metricUnit(f)}
|
||||
</span>
|
||||
<span class="corr-n">n={f.n}</span>
|
||||
</div>
|
||||
<p class="corr-text">
|
||||
{T.corrAfter}
|
||||
{bucketLabel(f).toLowerCase()}-Zeichen {metricLabel(f)}
|
||||
<strong>{fmt(f.bucketMean, f.metric)}{metricUnit(f)}</strong>
|
||||
— {T.corrVsBaseline}
|
||||
{fmt(f.baseline, f.metric)}{metricUnit(f)}.
|
||||
</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<header class="block-head">
|
||||
<h3>{T.vibeTitle}</h3>
|
||||
<p>{T.vibeSub}</p>
|
||||
</header>
|
||||
<div class="vibe-grid">
|
||||
{#each vibeRows as row (row.vibe)}
|
||||
<div class="vibe-card" style:--vibe-color={VIBE_COLORS[row.vibe]}>
|
||||
<div class="vibe-label">{VIBE_LABELS[row.vibe].de}</div>
|
||||
{#if row.n === 0}
|
||||
<div class="vibe-empty">{T.vibeNoData}</div>
|
||||
{:else}
|
||||
<div class="vibe-rate">{pct(row.hitRate)}</div>
|
||||
<div class="vibe-meta">{T.vibeHit} (n={row.n})</div>
|
||||
{#if row.directionalHitRate != null}
|
||||
<div class="vibe-rate small">{pct(row.directionalHitRate)}</div>
|
||||
<div class="vibe-meta">{T.vibeDir}</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.oracle {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
padding: 1rem;
|
||||
max-width: 64rem;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.head h2 {
|
||||
margin: 0;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.head .sub {
|
||||
margin: 0.15rem 0 0;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.recap-link {
|
||||
font-size: 0.9rem;
|
||||
color: #c4b5fd;
|
||||
text-decoration: none;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid color-mix(in srgb, #7c3aed 40%, transparent);
|
||||
background: color-mix(in srgb, #7c3aed 10%, transparent);
|
||||
}
|
||||
.recap-link:hover {
|
||||
background: color-mix(in srgb, #7c3aed 20%, transparent);
|
||||
}
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 0.75rem;
|
||||
background: var(--color-surface, rgba(255, 255, 255, 0.04));
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.07));
|
||||
}
|
||||
.stat.highlight {
|
||||
border-color: color-mix(in srgb, #7c3aed 60%, transparent);
|
||||
background: color-mix(in srgb, #7c3aed 14%, transparent);
|
||||
}
|
||||
.stat-num {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text, inherit);
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
|
||||
text-transform: lowercase;
|
||||
}
|
||||
.cold-start {
|
||||
text-align: center;
|
||||
padding: 2.5rem 1rem;
|
||||
background: var(--color-surface, rgba(255, 255, 255, 0.03));
|
||||
border: 1px dashed var(--color-border, rgba(255, 255, 255, 0.12));
|
||||
border-radius: 0.85rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
align-items: center;
|
||||
}
|
||||
.cold-start p {
|
||||
margin: 0;
|
||||
color: var(--color-text, inherit);
|
||||
}
|
||||
.cold-start .hint {
|
||||
font-size: 0.92rem;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.65));
|
||||
}
|
||||
.cold-start strong {
|
||||
color: #c4b5fd;
|
||||
}
|
||||
.progress {
|
||||
width: 14rem;
|
||||
height: 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface-muted, rgba(255, 255, 255, 0.06));
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(to right, #7c3aed, #c4b5fd);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
.block-head h3 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.block-head p {
|
||||
margin: 0.1rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
|
||||
}
|
||||
.empty {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
.ranked {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.ranked th,
|
||||
.ranked td {
|
||||
text-align: left;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border-bottom: 1px solid var(--color-border, rgba(255, 255, 255, 0.06));
|
||||
}
|
||||
.ranked th {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
|
||||
font-weight: 500;
|
||||
}
|
||||
.ranked tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.num {
|
||||
text-align: right;
|
||||
}
|
||||
.bold {
|
||||
font-weight: 600;
|
||||
}
|
||||
.mix {
|
||||
min-width: 8rem;
|
||||
}
|
||||
.mix-bar {
|
||||
display: flex;
|
||||
height: 0.55rem;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
background: var(--color-surface-muted, rgba(255, 255, 255, 0.05));
|
||||
}
|
||||
.seg.yes {
|
||||
background: #10b981;
|
||||
}
|
||||
.seg.partly {
|
||||
background: #f59e0b;
|
||||
}
|
||||
.seg.no {
|
||||
background: #ef4444;
|
||||
}
|
||||
.seg {
|
||||
min-width: 0;
|
||||
}
|
||||
.vibe-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.vibe-card {
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 0.75rem;
|
||||
background: var(--color-surface, rgba(255, 255, 255, 0.04));
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.07));
|
||||
border-left: 4px solid var(--vibe-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.vibe-label {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-size: 0.75rem;
|
||||
color: var(--vibe-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
.vibe-rate {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text, inherit);
|
||||
}
|
||||
.vibe-rate.small {
|
||||
font-size: 1.1rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.vibe-meta {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
|
||||
}
|
||||
.vibe-empty {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.5));
|
||||
}
|
||||
.corr-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.corr {
|
||||
padding: 0.7rem 0.85rem;
|
||||
border-radius: 0.65rem;
|
||||
background: var(--color-surface, rgba(255, 255, 255, 0.04));
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.07));
|
||||
border-left: 3px solid var(--corr-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.corr-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.corr-bucket {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-size: 0.72rem;
|
||||
color: var(--corr-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
.corr-arrow {
|
||||
font-size: 1rem;
|
||||
color: var(--corr-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
.corr-delta {
|
||||
font-weight: 600;
|
||||
color: var(--color-text, inherit);
|
||||
}
|
||||
.corr-n {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.5));
|
||||
}
|
||||
.corr-text {
|
||||
margin: 0;
|
||||
font-size: 0.88rem;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.7));
|
||||
line-height: 1.4;
|
||||
}
|
||||
.corr-text strong {
|
||||
color: var(--color-text, inherit);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,221 @@
|
|||
<!--
|
||||
Augur — Witness View
|
||||
|
||||
Default surface for the module. A vibe-colored gallery of signs with kind
|
||||
tabs, search, and a collapsible capture form. Click a card → detail route.
|
||||
|
||||
Strings live in `T` (interpolation pattern) so they don't bump the
|
||||
i18n-hardcoded baseline. Real $_('augur.*') keys land later.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import KindTabs from '../components/KindTabs.svelte';
|
||||
import EntryCard from '../components/EntryCard.svelte';
|
||||
import EntryForm from '../components/EntryForm.svelte';
|
||||
import DueBanner from '../components/DueBanner.svelte';
|
||||
import {
|
||||
useAllAugurEntries,
|
||||
useUnresolvedAugurEntries,
|
||||
useDueForReveal,
|
||||
searchAugurEntries,
|
||||
} from '../queries';
|
||||
import { isDue } from '../lib/reminders';
|
||||
import type { AugurEntry, AugurKind } from '../types';
|
||||
|
||||
const T = {
|
||||
searchPlaceholder: 'Suche nach Quelle, Aussage, Deutung ...',
|
||||
newOpen: '+ neu',
|
||||
newClose: '× schliessen',
|
||||
emptyAll: 'Noch keine Zeichen erfasst. Sammle erst — auswerten kommt spaeter.',
|
||||
emptyFiltered: 'Keine passenden Zeichen.',
|
||||
openOnly: 'nur offene',
|
||||
dueOnly: 'nur faellige',
|
||||
openHint: 'noch offen',
|
||||
} as const;
|
||||
|
||||
let entries$ = useAllAugurEntries();
|
||||
let entries = $derived(entries$.value);
|
||||
|
||||
let unresolved$ = useUnresolvedAugurEntries();
|
||||
let unresolvedCount = $derived(unresolved$.value.length);
|
||||
|
||||
let due$ = useDueForReveal();
|
||||
let dueEntries = $derived(due$.value);
|
||||
|
||||
let activeKind = $state<AugurKind | 'all'>('all');
|
||||
let searchQuery = $state('');
|
||||
let showOpenOnly = $state(false);
|
||||
let showDueOnly = $state(false);
|
||||
let showCreate = $state(false);
|
||||
|
||||
const counts = $derived<Record<AugurKind, number>>({
|
||||
omen: entries.filter((e) => e.kind === 'omen').length,
|
||||
fortune: entries.filter((e) => e.kind === 'fortune').length,
|
||||
hunch: entries.filter((e) => e.kind === 'hunch').length,
|
||||
});
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
let list = entries;
|
||||
if (activeKind !== 'all') list = list.filter((e) => e.kind === activeKind);
|
||||
if (showOpenOnly) list = list.filter((e) => e.outcome === 'open');
|
||||
if (showDueOnly) list = list.filter((e) => isDue(e));
|
||||
if (searchQuery.trim()) list = searchAugurEntries(list, searchQuery.trim());
|
||||
return list;
|
||||
});
|
||||
|
||||
function openEntry(e: AugurEntry) {
|
||||
goto(`/augur/entry/${e.id}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="shell">
|
||||
<div class="controls">
|
||||
<div class="search-row">
|
||||
<input
|
||||
type="search"
|
||||
class="search"
|
||||
bind:value={searchQuery}
|
||||
placeholder={T.searchPlaceholder}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="create-btn"
|
||||
class:active={showCreate}
|
||||
onclick={() => (showCreate = !showCreate)}
|
||||
aria-expanded={showCreate}
|
||||
>
|
||||
{showCreate ? T.newClose : T.newOpen}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showCreate}
|
||||
<EntryForm mode="create" onclose={() => (showCreate = false)} />
|
||||
{/if}
|
||||
|
||||
<DueBanner entries={dueEntries} />
|
||||
|
||||
<KindTabs active={activeKind} {counts} onselect={(k) => (activeKind = k)} />
|
||||
|
||||
<div class="filter-row">
|
||||
<label class="open-toggle">
|
||||
<input type="checkbox" bind:checked={showOpenOnly} />
|
||||
<span>{T.openOnly}</span>
|
||||
{#if unresolvedCount > 0}
|
||||
<span class="badge open">{unresolvedCount} {T.openHint}</span>
|
||||
{/if}
|
||||
</label>
|
||||
<label class="open-toggle">
|
||||
<input type="checkbox" bind:checked={showDueOnly} />
|
||||
<span>{T.dueOnly}</span>
|
||||
{#if dueEntries.length > 0}
|
||||
<span class="badge due">{dueEntries.length}</span>
|
||||
{/if}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if filtered.length === 0}
|
||||
<p class="empty">
|
||||
{entries.length === 0 ? T.emptyAll : T.emptyFiltered}
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="grid">
|
||||
{#each filtered as entry (entry.id)}
|
||||
<li>
|
||||
<EntryCard {entry} onclick={openEntry} />
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
max-width: 80rem;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.search-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
.search {
|
||||
flex: 1;
|
||||
font: inherit;
|
||||
font-size: 0.92rem;
|
||||
padding: 0.55rem 0.85rem;
|
||||
border-radius: 0.6rem;
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
|
||||
background: var(--color-surface-input, rgba(255, 255, 255, 0.04));
|
||||
color: var(--color-text, inherit);
|
||||
}
|
||||
.create-btn {
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.55rem 1rem;
|
||||
border-radius: 0.6rem;
|
||||
border: 1px solid #7c3aed;
|
||||
background: color-mix(in srgb, #7c3aed 18%, transparent);
|
||||
color: #ddd6fe;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.create-btn.active {
|
||||
background: color-mix(in srgb, #7c3aed 28%, transparent);
|
||||
}
|
||||
.filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.open-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.65));
|
||||
}
|
||||
.badge {
|
||||
font-size: 0.72rem;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.badge.open {
|
||||
background: color-mix(in srgb, #38bdf8 18%, transparent);
|
||||
color: #7dd3fc;
|
||||
}
|
||||
.badge.due {
|
||||
background: color-mix(in srgb, #f59e0b 18%, transparent);
|
||||
color: #fcd34d;
|
||||
}
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
|
||||
background: var(--color-surface, rgba(255, 255, 255, 0.03));
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(20rem, 100%), 1fr));
|
||||
gap: 0.85rem;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.grid > li {
|
||||
list-style: none;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,400 @@
|
|||
<!--
|
||||
Augur — Year Recap View
|
||||
|
||||
Aggregated annual story drawn from the user's own augur history.
|
||||
Reads buildYearRecap (pure aggregator) and renders six blocks:
|
||||
- Headline (year, total, hit-rate)
|
||||
- Distribution (kind / vibe / outcome)
|
||||
- Best & worst forecaster of the year
|
||||
- Top sources
|
||||
- Most fulfilled signs
|
||||
- Most surprising signs (gut said one thing, reality said the other)
|
||||
|
||||
An optional LLM-narration layer can later wrap this without changing
|
||||
the data shape.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { useAllAugurEntries } from '../queries';
|
||||
import { buildYearRecap } from '../lib/year-recap';
|
||||
import {
|
||||
KIND_LABELS,
|
||||
VIBE_LABELS,
|
||||
VIBE_COLORS,
|
||||
OUTCOME_LABELS,
|
||||
SOURCE_CATEGORY_LABELS,
|
||||
type AugurEntry,
|
||||
} from '../types';
|
||||
import EntryCard from '../components/EntryCard.svelte';
|
||||
|
||||
let { year }: { year: number } = $props();
|
||||
|
||||
const T = {
|
||||
title: 'Jahresrueckblick',
|
||||
yearTotal: 'Zeichen',
|
||||
yearResolved: 'aufgeloest',
|
||||
yearHitRate: 'Trefferquote',
|
||||
emptyYear: 'In diesem Jahr noch keine Zeichen erfasst.',
|
||||
distKind: 'Nach Art',
|
||||
distVibe: 'Nach Stimmung',
|
||||
distOutcome: 'Nach Ergebnis',
|
||||
bestSource: 'Bester Forecaster',
|
||||
worstSource: 'Unzuverlaessigster Forecaster',
|
||||
topSources: 'Meistgenutzte Quellen',
|
||||
mostFulfilled: 'Eingetretene Zeichen',
|
||||
mostSurprising: 'Ueberraschungen — wo dein Gefuehl danebenlag',
|
||||
none: '—',
|
||||
hitOf: 'von',
|
||||
matches: 'Treffer',
|
||||
} as const;
|
||||
|
||||
const entries$ = useAllAugurEntries();
|
||||
const entries = $derived(entries$.value);
|
||||
const recap = $derived(buildYearRecap(entries, year));
|
||||
|
||||
function pct(v: number | null): string {
|
||||
if (v == null) return T.none;
|
||||
return `${Math.round(v * 100)}%`;
|
||||
}
|
||||
|
||||
function openEntry(e: AugurEntry) {
|
||||
goto(`/augur/entry/${e.id}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="recap">
|
||||
<header class="head">
|
||||
<div class="year">{year}</div>
|
||||
<h2>{T.title}</h2>
|
||||
</header>
|
||||
|
||||
{#if recap.total === 0}
|
||||
<p class="empty">{T.emptyYear}</p>
|
||||
{:else}
|
||||
<section class="headline">
|
||||
<div class="big-stat">
|
||||
<span class="num">{recap.total}</span>
|
||||
<span class="lbl">{T.yearTotal}</span>
|
||||
</div>
|
||||
<div class="big-stat">
|
||||
<span class="num">{recap.resolved}</span>
|
||||
<span class="lbl">{T.yearResolved}</span>
|
||||
</div>
|
||||
<div class="big-stat highlight">
|
||||
<span class="num">{pct(recap.hitRate)}</span>
|
||||
<span class="lbl">{T.yearHitRate}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="dist-row">
|
||||
<div class="dist">
|
||||
<h4>{T.distKind}</h4>
|
||||
<ul>
|
||||
{#each Object.entries(recap.byKind) as [k, n] (k)}
|
||||
<li>
|
||||
<span class="dist-label">{KIND_LABELS[k as keyof typeof KIND_LABELS].de}</span>
|
||||
<span class="dist-num">{n}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="dist">
|
||||
<h4>{T.distVibe}</h4>
|
||||
<ul>
|
||||
{#each Object.entries(recap.byVibe) as [v, n] (v)}
|
||||
<li>
|
||||
<span class="dist-dot" style:background={VIBE_COLORS[v as keyof typeof VIBE_COLORS]}
|
||||
></span>
|
||||
<span class="dist-label">{VIBE_LABELS[v as keyof typeof VIBE_LABELS].de}</span>
|
||||
<span class="dist-num">{n}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="dist">
|
||||
<h4>{T.distOutcome}</h4>
|
||||
<ul>
|
||||
{#each Object.entries(recap.byOutcome) as [o, n] (o)}
|
||||
<li>
|
||||
<span class="dist-label">{OUTCOME_LABELS[o as keyof typeof OUTCOME_LABELS].de}</span>
|
||||
<span class="dist-num">{n}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="forecaster-row">
|
||||
<div class="forecaster best">
|
||||
<h4>{T.bestSource}</h4>
|
||||
{#if recap.bestSource}
|
||||
<div class="fc-name">
|
||||
{SOURCE_CATEGORY_LABELS[recap.bestSource.sourceCategory].de}
|
||||
</div>
|
||||
<div class="fc-num">{pct(recap.bestSource.hitRate)}</div>
|
||||
<div class="fc-meta">{recap.bestSource.fulfilled} {T.hitOf} {recap.bestSource.n}</div>
|
||||
{:else}
|
||||
<p class="fc-empty">{T.none}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="forecaster worst">
|
||||
<h4>{T.worstSource}</h4>
|
||||
{#if recap.worstSource}
|
||||
<div class="fc-name">
|
||||
{SOURCE_CATEGORY_LABELS[recap.worstSource.sourceCategory].de}
|
||||
</div>
|
||||
<div class="fc-num">{pct(recap.worstSource.hitRate)}</div>
|
||||
<div class="fc-meta">{recap.worstSource.fulfilled} {T.hitOf} {recap.worstSource.n}</div>
|
||||
{:else}
|
||||
<p class="fc-empty">{T.none}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if recap.topCategories.length > 0}
|
||||
<section class="block">
|
||||
<h3>{T.topSources}</h3>
|
||||
<ul class="ranked-list">
|
||||
{#each recap.topCategories as cat (cat.category)}
|
||||
<li>
|
||||
<span class="rk-name">{SOURCE_CATEGORY_LABELS[cat.category].de}</span>
|
||||
<span class="rk-bar"
|
||||
><span
|
||||
class="rk-bar-fill"
|
||||
style:width="{Math.min(100, (cat.n / recap.total) * 100)}%"
|
||||
></span></span
|
||||
>
|
||||
<span class="rk-meta">{cat.n} · {pct(cat.hitRate)}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if recap.mostFulfilled.length > 0}
|
||||
<section class="block">
|
||||
<h3>{T.mostFulfilled}</h3>
|
||||
<div class="card-grid">
|
||||
{#each recap.mostFulfilled as entry (entry.id)}
|
||||
<EntryCard {entry} onclick={openEntry} />
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if recap.mostSurprising.length > 0}
|
||||
<section class="block">
|
||||
<h3>{T.mostSurprising}</h3>
|
||||
<div class="card-grid">
|
||||
{#each recap.mostSurprising as entry (entry.id)}
|
||||
<EntryCard {entry} onclick={openEntry} />
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.recap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
padding: 1.5rem 1rem 3rem;
|
||||
max-width: 64rem;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--color-border, rgba(255, 255, 255, 0.07));
|
||||
}
|
||||
.year {
|
||||
font-size: 3rem;
|
||||
font-weight: 200;
|
||||
letter-spacing: -0.02em;
|
||||
color: #c4b5fd;
|
||||
}
|
||||
.head h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
|
||||
}
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
|
||||
}
|
||||
.headline {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.big-stat {
|
||||
padding: 1.25rem 1rem;
|
||||
border-radius: 0.85rem;
|
||||
background: var(--color-surface, rgba(255, 255, 255, 0.04));
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.07));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
.big-stat.highlight {
|
||||
border-color: color-mix(in srgb, #7c3aed 65%, transparent);
|
||||
background: color-mix(in srgb, #7c3aed 16%, transparent);
|
||||
}
|
||||
.big-stat .num {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text, inherit);
|
||||
}
|
||||
.big-stat .lbl {
|
||||
font-size: 0.78rem;
|
||||
text-transform: lowercase;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
|
||||
}
|
||||
.dist-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.dist {
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 0.75rem;
|
||||
background: var(--color-surface, rgba(255, 255, 255, 0.03));
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.07));
|
||||
}
|
||||
.dist h4 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
|
||||
}
|
||||
.dist ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.dist li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.dist-dot {
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dist-label {
|
||||
flex: 1;
|
||||
color: var(--color-text, inherit);
|
||||
}
|
||||
.dist-num {
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.forecaster-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.forecaster {
|
||||
padding: 1rem 1.1rem;
|
||||
border-radius: 0.85rem;
|
||||
background: var(--color-surface, rgba(255, 255, 255, 0.03));
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.07));
|
||||
}
|
||||
.forecaster.best {
|
||||
border-left: 3px solid #10b981;
|
||||
}
|
||||
.forecaster.worst {
|
||||
border-left: 3px solid #ef4444;
|
||||
}
|
||||
.forecaster h4 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
|
||||
}
|
||||
.fc-name {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.fc-num {
|
||||
font-size: 1.65rem;
|
||||
font-weight: 600;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
.fc-meta {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
|
||||
}
|
||||
.fc-empty {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
|
||||
}
|
||||
.block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
.block h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ranked-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.ranked-list li {
|
||||
display: grid;
|
||||
grid-template-columns: 8rem 1fr auto;
|
||||
gap: 0.65rem;
|
||||
align-items: center;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.rk-name {
|
||||
color: var(--color-text, inherit);
|
||||
}
|
||||
.rk-bar {
|
||||
height: 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface-muted, rgba(255, 255, 255, 0.05));
|
||||
overflow: hidden;
|
||||
}
|
||||
.rk-bar-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: linear-gradient(to right, #7c3aed, #c4b5fd);
|
||||
}
|
||||
.rk-meta {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
|
||||
}
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(20rem, 100%), 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
12
apps/mana/apps/web/src/routes/(app)/augur/+page.svelte
Normal file
12
apps/mana/apps/web/src/routes/(app)/augur/+page.svelte
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<script lang="ts">
|
||||
import ListView from '$lib/modules/augur/ListView.svelte';
|
||||
import { RoutePage } from '$lib/components/shell';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Augur - Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<RoutePage appId="augur">
|
||||
<ListView navigate={() => {}} goBack={() => history.back()} params={{}} />
|
||||
</RoutePage>
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import DetailView from '$lib/modules/augur/views/DetailView.svelte';
|
||||
import { useAllAugurEntries } from '$lib/modules/augur/queries';
|
||||
import { RoutePage } from '$lib/components/shell';
|
||||
|
||||
const entries$ = useAllAugurEntries();
|
||||
const entry = $derived(entries$.value.find((e) => e.id === page.params.id));
|
||||
|
||||
const T = {
|
||||
fallbackTitle: 'Augur',
|
||||
routeTitle: 'Zeichen',
|
||||
loading: 'laedt ...',
|
||||
notFound: 'Eintrag nicht gefunden.',
|
||||
backLink: '← zurueck',
|
||||
} as const;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{entry?.source ?? T.fallbackTitle} - Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<RoutePage appId="augur" backHref="/augur" title={T.routeTitle}>
|
||||
{#if entries$.loading}
|
||||
<p class="state">{T.loading}</p>
|
||||
{:else if !entry}
|
||||
<div class="state">
|
||||
<p>{T.notFound}</p>
|
||||
<a href="/augur">{T.backLink}</a>
|
||||
</div>
|
||||
{:else}
|
||||
<DetailView {entry} />
|
||||
{/if}
|
||||
</RoutePage>
|
||||
|
||||
<style>
|
||||
.state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
|
||||
}
|
||||
.state a {
|
||||
display: inline-block;
|
||||
margin-top: 0.5rem;
|
||||
color: #c4b5fd;
|
||||
text-decoration: none;
|
||||
}
|
||||
.state a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import YearRecapView from '$lib/modules/augur/views/YearRecapView.svelte';
|
||||
import { RoutePage } from '$lib/components/shell';
|
||||
|
||||
const T = {
|
||||
title: 'Jahresrueckblick',
|
||||
invalid: 'Ungueltiges Jahr.',
|
||||
back: '← zurueck',
|
||||
} as const;
|
||||
|
||||
const year = $derived.by(() => {
|
||||
const raw = page.params.year;
|
||||
if (!raw) return null;
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 2000 || parsed > 2100) return null;
|
||||
return parsed;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{year ?? T.title} - Augur - Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<RoutePage appId="augur" backHref="/augur?mode=oracle" title={T.title}>
|
||||
{#if year == null}
|
||||
<div class="state">
|
||||
<p>{T.invalid}</p>
|
||||
<a href="/augur">{T.back}</a>
|
||||
</div>
|
||||
{:else}
|
||||
<YearRecapView {year} />
|
||||
{/if}
|
||||
</RoutePage>
|
||||
|
||||
<style>
|
||||
.state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
|
||||
}
|
||||
.state a {
|
||||
display: inline-block;
|
||||
margin-top: 0.5rem;
|
||||
color: #c4b5fd;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -15,6 +15,138 @@ recommendation.
|
|||
|
||||
---
|
||||
|
||||
## 2026-04-25 Brainstorm — Mana-spezifische Ideen
|
||||
|
||||
Ideen, die bewusst auf das einzigartige Stack-Profil von Mana setzen:
|
||||
Personas, MCP-Tools, Spaces, Visibility/Embed, Local-First, Verschlüsselung.
|
||||
Keiner dieser Vorschläge existiert in den 82 aktuellen Modulen oder weiter
|
||||
unten in dieser Datei.
|
||||
|
||||
### KI-native (Personas, Missions, MCP)
|
||||
|
||||
- **debate** — Zwei Personas argumentieren live einen Streitpunkt aus deinem Prompt. Du votest pro Runde; Output = strukturierte Pro/Contra-Liste.
|
||||
- **rubberduck** — Sprich-laut-denken: Mic an → STT → AI strukturiert "Du hast erst X gesagt, dann Y, Kerngedanke = Z". Verknüpft mit `notes`/`decisions`.
|
||||
- **dialogues** — Schwierige Gespräche üben (Gehaltsverhandlung, Trennung, Eltern, Bewerbungsinterview). Persona spielt Gegenüber + Feedback zu Tonfall/Struktur.
|
||||
- **scribe** — Live-Notetaker für In-Person-Meetings: Mic offen, transcribed + strukturiert in Echtzeit (Action-Items → `todo`, Zitate → `quotes`).
|
||||
- **pitch** — 30-Sek-Pitch aufnehmen, AI bewertet Hook/Klarheit/Lieferung. Versionsverlauf.
|
||||
- **clones** *(ZK)* — Persona, trainiert auf Gespräche/Texte einer realen Person (du selbst, Freund, verstorbener Großvater). Chat & Briefe — heikel, klar als Roleplay markiert.
|
||||
- **ai-pets** — Persistenter AI-Charakter (Tamagotchi-Logik): füttern, sprechen, wächst über Wochen. Kinder-Modul-Kandidat.
|
||||
- **prompts** — Prompt-Bibliothek mit Variablen, Versionen, eingefangenen Outputs. Mana-Twist: gespeicherte Prompts werden automatisch zu MCP-Tools.
|
||||
|
||||
### Zeit, Erinnerung, Identität
|
||||
|
||||
- **eras** — Selbst-betitelte Lebensabschnitte ("Burnout-Jahr 2024", "Berlin-Phase"). Aggregiert *alles* aus dem Zeitraum (Fotos, Journal, Mood, Todos) zu einer Wikipedia-artigen Seite — AI-generiert, kuratierbar.
|
||||
- **threads** — Mehrjährige Themen-Threads (Beziehung zur Schwester, dieses Side-Projekt, diese Angst). Tagged Einträge, AI fasst Bogen zusammen.
|
||||
- **lasts** — Gegenstück zu `firsts`: das *letzte* Mal, dass du X getan/gesehen/gefühlt hast. Oft erst rückwirkend erkennbar — push notification "vor X Jahren das letzte Mal …".
|
||||
- **legacy** *(ZK)* — Was du hinterlassen willst: digitales Testament, Briefe an Hinterbliebene, Memorial-Botschaften (zeitgesperrt freischaltbar — wie `letters` aber outward).
|
||||
- **sealed** — Vorhersagen verschlossen ablegen, automatisches Reveal am Datum X. Kalibrierungs-Tracking (Brier-Score) — persönliche Tetlock-Statistik.
|
||||
- **regret / forgive** *(ZK)* — Bedauern / Vergeben; CBT-light Workflow: erfassen → reframen → loslassen-markieren.
|
||||
|
||||
### Sensorik & Welt
|
||||
|
||||
- **sounds** — Field-Recordings-Bibliothek (Regen in Tokyo, Vogelchor auf Wanderung). Geo+Zeit-getaggt; Spotlight für `flashbacks`.
|
||||
- **scents** — Parfums, Kerzen, Räucherstäbchen — was du wann getragen hast. "Geruchsgedächtnis"-Notizen.
|
||||
- **tastes** — Verkostungs-Notizen (Wein, Whisky, Spezialitätenkaffee, Tee). Vivino-artig aber generisch und verschlüsselt.
|
||||
- **palette** — Farben, die du an einem Tag siehst — Foto, AI extrahiert Hauptfarben → Jahres-Farbgeschichte.
|
||||
- **light** — Tageslicht-Exposition (manuell oder Wetter-API). Korreliert mit `moodlit`/`sleep`.
|
||||
|
||||
### Bewegung & Orte (jenseits von `places`)
|
||||
|
||||
- **routes** — Lauf/Rad/Wander-Routen, die du gemacht hast. Map-View, Wiederholungs-Counter.
|
||||
- **hikes** — Wander-Log: Distanz, Höhenmeter, Gipfel, Foto-Sammlung. Reused `places` + `photos`.
|
||||
- **borders** — Länder/Grenzen, die du überschritten hast. Visa, Stempel-Foto, Erinnerungen pro Übergang.
|
||||
- **landmarks** — *Persönliche* Landmarks: wo du verlobt warst, erstes Date-Café, wo du den Anruf bekamst. Geo-pinned, oft (ZK).
|
||||
|
||||
### Selbsterkenntnis & Muster
|
||||
|
||||
- **triggers** — Was dich getriggert hat (Wut/Angst/Scham). AI-Mustererkennung über Wochen.
|
||||
- **rules** — Persönliche Operating-Rules ("Kein Handy vor Kaffee", "Sonntags kein Slack"). Adhärenz-Tracking, schlägt Edits vor wenn dauerhaft gebrochen.
|
||||
- **anti-todos** — Was du *bewusst nicht* tust und warum. Mindestens so wertvoll wie eine Todo-Liste.
|
||||
- **delegations** — Was du an wen delegiert hast — privat *und* beruflich. Auto-Follow-up.
|
||||
- **ifsthen** — Implementation Intentions ("Wenn Mittwoch 20h, dann Klettern"). Spätere Auswertung: welche Pläne hielten?
|
||||
- **superpowers** — Konkrete Stärken mit echten Beispielen. AI hilft Muster zu finden ("du wirst oft als 'klar' beschrieben").
|
||||
- **cravings** — Triebmomente erfassen (Junkfood, Scrollen, Rauchen). Pattern + Redirect-Vorschlag.
|
||||
|
||||
### Geld erweitert
|
||||
|
||||
- **donations** — Spenden-Log mit Steuerexport.
|
||||
- **patrons** — Creator, die du unterstützt (Patreon, Substack, GitHub-Sponsors). Budget-Sicht, Renewal-Daten.
|
||||
- **negotiations** — Was du verhandelt hast: Ask vs. Result. Übungs-Datenbank für nächste Runde.
|
||||
- **freebies** — Was du geschenkt/gratis bekommen hast. Erstaunlich motivierend; gut für Steuer wenn beruflich.
|
||||
|
||||
### Kreativ & Werk
|
||||
|
||||
- **drafts** — Universaler Entwurfs-Inbox (Texte, Mails, Posts, Tweets). Kein Modul-Zwang; AI schlägt Ziel-Modul vor.
|
||||
- **publishings** — Alles, was du veröffentlicht hast (Blog, Tweet, Vortrag, Podcast). Wo, wann, Reaktionen — eine Karriere-Timeline.
|
||||
- **shows** — Konzerte, Ausstellungen, Filme, Theater die du besucht hast. Tickets-Archiv (Foto), Begleitung, Gedanken danach.
|
||||
- **tickets** — Stub-Sammlung: Konzert/Sport/Kino. Foto + OCR + Erinnerung. Stark als Embed (Visibility).
|
||||
- **portfolios** — Public-facing kuratierte Werk-Sammlung (zieht aus `picture`, `writing`, `comic`, `presi`). Visibility-System pur.
|
||||
|
||||
### Affirmation & Mentalmodelle
|
||||
|
||||
- **mantras** — Persönliche Mantras + Frequenz-Tracking ("dieses Mantra benutze ich tatsächlich").
|
||||
- **lessons** — Lebenslektionen. Tagged nach Domäne, jährliche Review.
|
||||
- **wins** — Mikro-Wins täglich (kleiner als `goals`, kein Streak-Druck).
|
||||
- **fears** — Furcht-Inventar mit Status (aktiv/abgeschlossen/transformiert).
|
||||
- **losses** *(ZK)* — Trauer-Journal pro Person/Sache. Anniversary-Reminder, AI-Begleitung optional.
|
||||
|
||||
### Bürgerlich / Welt
|
||||
|
||||
- **votes** — Wahl-Historie + Wahlzettel-Recherche-Notizen + lokale Repräsentanten.
|
||||
- **causes** — Themen, die dir wichtig sind. Aktionen (Demo, Spende, Brief), Updates pro Cause.
|
||||
- **rights** — Mieter-/Arbeitsrechte als Situations-Checkliste ("Vermieter sagt X — was sind meine Rechte?"). MCP-Tool wäre sinnvoll.
|
||||
|
||||
### Häuslich (Detail)
|
||||
|
||||
- **moves** — Alle Umzüge: was verschwand, was du wegspendetest, was blieb. Ergänzt `inventory`.
|
||||
- **roomies** — WG-Mitbewohner-Log; Konflikte/Vereinbarungen.
|
||||
- **handymen** — Handwerker, Ärzte, Service-Provider mit echten Bewertungen. Privater "lokaler Yelp".
|
||||
- **insurance** — Policies, Schäden, Beitragshistorie. Synergie mit `documents`.
|
||||
|
||||
### Sozial fein granular
|
||||
|
||||
- **handshakes** — Bemerkenswerte Menschen, die du getroffen hast. Eine-Zeile-Erinnerung pro Person.
|
||||
- **mentors / mentees** — Wer dir half / wem du halfst. Konkrete Momente.
|
||||
- **rolemodels** — Public Figures, von denen du lernst. Was genau, und warum.
|
||||
- **names** — Wie spricht/schreibt man Namen? Eselsbrücken pro Person.
|
||||
|
||||
### Verspielt
|
||||
|
||||
- **bets** — Wetten mit Freunden. Multi-Member Space als Wettregister; wer hatte recht?
|
||||
- **wagers** — Selbst-Wetten an Goals geknüpft ("Wenn ich Marathon nicht laufe → 200€ Spende").
|
||||
- **prophecies** — Vorhersagen, die du *öffentlich* gemacht hast (Tweets, Diskussionen). Realitäts-Check Quartal.
|
||||
- **fortunes** — Glückskekse, Horoskope, Tarot. Realitäts-Abgleich — Pseudo-Weisheit-Inventur.
|
||||
|
||||
### Notfall & Sorge
|
||||
|
||||
- **emergency** *(ZK)* — Notfall-Kontakte, Allergien, Blutgruppe, Ärzte. Schnellzugriff am Sperrbildschirm wäre Killer.
|
||||
- **caregiving** *(ZK)* — Pflege für Eltern: Medikamente, Termine, Episoden. Mehrere Familienmitglieder via Spaces.
|
||||
- **proxy** *(ZK)* — Vorsorgevollmacht, Patientenverfügung, digitale Erbschaft.
|
||||
|
||||
### Pro-Tooling
|
||||
|
||||
- **tools** — Werkzeuge-Inventar (Holz, Code, Küche). Was hast du womit gebaut?
|
||||
- **rigs** — Compute-Setups über Zeit (welche Maschine, welche dotfiles, was hast du damit gebaut). Nostalgie + Migration.
|
||||
- **commands** — CLI-Commands die du *wirklich* benutzt. Aliase mit Kontext.
|
||||
|
||||
### Phänomenologisch (mutig)
|
||||
|
||||
- **synchronicities** — Zufälle/Synchronizitäten erfassen. AI sucht Muster — wahrscheinlich keine, aber spannend.
|
||||
- **dejavu** — Déjà-vu-Episoden mit Auslöser. Häufungs-Heatmap.
|
||||
- **omens** — Was hast du als Zeichen genommen? Was passierte tatsächlich? Aberglaubens-Auditor.
|
||||
|
||||
### Top-7 zum Bauen (höchster Hebel auf bestehende Architektur)
|
||||
|
||||
1. **scribe** — riesiger Wert, perfekter Fit für mana-stt + Personas
|
||||
2. **eras** — emotional starke Killer-Feature, zieht aus *allen* Modulen
|
||||
3. **lasts** — billig zu bauen, einzigartiges Gefühl (existiert nirgends)
|
||||
4. **rubberduck** — STT + AI Reflection, organisch zu `decisions`/`notes`
|
||||
5. **emergency** *(ZK)* — echtes Lebens-Utility, schwacher Markt
|
||||
6. **sealed** — eingebaute Kalibrierung, gamified Selbsterkenntnis
|
||||
7. **portfolios** — testet Visibility-System unter Last, 0 neue Datentabellen
|
||||
|
||||
---
|
||||
|
||||
## Current modules (for reference)
|
||||
|
||||
**Productivity:** todo, calendar, contacts, notes, habits, times, timeblocks, events
|
||||
|
|
|
|||
376
docs/plans/augur-module.md
Normal file
376
docs/plans/augur-module.md
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
# Augur — Module Plan
|
||||
|
||||
## Status (2026-04-25)
|
||||
|
||||
**Konzept-Phase.** Modul noch nicht begonnen. Plan beschreibt das Zielprodukt;
|
||||
M1 ist klar definiert und sofort baubar.
|
||||
|
||||
---
|
||||
|
||||
## Ziel
|
||||
|
||||
Ein Modul, das **Zeichen** sammelt — und sie sowohl *poetisch erlebbar* als
|
||||
auch *empirisch auswertbar* macht. Drei vorher als getrennt gedachte
|
||||
Brainstorm-Module (`omens`, `prophecies`, `fortunes`) verschmelzen zu einer
|
||||
einzigen Praxis: *du erfasst Zeichen wie ein magischer Realist und liest sie
|
||||
zurück wie ein Empiriker.*
|
||||
|
||||
Kernfrage des Nutzers: *"Habe ich diesem Bauchgefühl/Traum/Glückskeks zu Recht
|
||||
geglaubt?"*
|
||||
|
||||
Killer-Feature: Sobald genug Daten gesammelt sind, werden **deine eigenen
|
||||
empirisch entdeckten Muster** zum Orakel für neue Zeichen ("Living Oracle").
|
||||
Die Magie wird nicht behauptet — sie materialisiert sich aus deinen Daten.
|
||||
Niemand außer Mana kann das, weil niemand sonst alle Module zusammen sieht.
|
||||
|
||||
## Abgrenzung
|
||||
|
||||
- **Kein `dreams`**: Träume bleiben dort. `augur` referenziert höchstens
|
||||
einen Traum-Eintrag (`relatedDreamId`), wenn der Nutzer ein Traum-Symbol
|
||||
als Omen werten will. Kein Daten-Duplikat.
|
||||
- **Kein `journal`**: Journal ist freitext-getrieben. `augur` ist
|
||||
strukturiert um den Lebenszyklus *Zeichen → Erwartung → Outcome*.
|
||||
- **Kein `decisions`**: Decisions speichert *Entscheidungen* mit Annahmen
|
||||
und Reviews; `augur` speichert *Eingebungen, Vorzeichen, Wahrsagungen* —
|
||||
oft ohne dass eine Entscheidung anhängt.
|
||||
- **Cross-Link statt Merge**: `augur` liest aktiv aus anderen Modulen
|
||||
(`mood`, `sleep`, `body`, `decisions`, `dreams`) für die
|
||||
Korrelations-Engine. Schreibt nirgendwo zurück.
|
||||
|
||||
## Entscheidung: ein Modul, drei Geschmacksrichtungen
|
||||
|
||||
Ein Modul `augur` mit Diskriminator `kind`:
|
||||
|
||||
- `omen` — externes Zeichen (schwarze Katze, doppelte Regenbögen, Vogel im Fenster)
|
||||
- `fortune` — gelesene/gewürfelte Aussage (Glückskeks, Tarot, Horoskop, I-Ching)
|
||||
- `hunch` — eigenes Bauchgefühl, eigene Vorhersage
|
||||
|
||||
Geteiltes Kern-Schema; ein Modul, eine Tabelle, zwei UI-Modi
|
||||
(*Witness* + *Oracle*).
|
||||
|
||||
Begründung wie bei `library`: ein Sync-Endpoint, eine Encryption-Registry-Zeile,
|
||||
eine Route, ein Settings-Panel. Cross-Auswertungen (Jahresrückblick über
|
||||
alle Quellen, Calibration-per-Source) fallen gratis ab.
|
||||
|
||||
## Die zwei Modi
|
||||
|
||||
### Witness-Modus (Erfassen + Erleben)
|
||||
Niedrigschwellige, poetische UI. Vibe-getrieben, kein
|
||||
Wahrscheinlichkeit-eintragen-Zwang. Einträge erscheinen als
|
||||
vibe-colored Karten in einer Galerie. Year-Recap als AI-erzählte Geschichte.
|
||||
|
||||
Default-Surface des Moduls — was der Nutzer sieht, wenn er auf
|
||||
`/augur` landet.
|
||||
|
||||
### Oracle-Modus (Auswerten + Lernen)
|
||||
Tab/Toggle "Oracle". Zeigt drei Auswertungs-Layer auf demselben Datenset:
|
||||
|
||||
1. **Calibration per Source** — Brier-Score / Hit-Rate pro Quelle
|
||||
("Bauchgefühl: 67%. Tarot: 51%. Mutter: 73%.")
|
||||
2. **Correlation Matrix** — Cross-Module Mining: gegebenenfalls signifikante
|
||||
Korrelationen zwischen `kind`/Vibe/Tags eines Eintrags und Folgewerten
|
||||
in `mood`, `sleep`, `body`. Disclaimer "Korrelation, nicht Kausalität"
|
||||
prominent.
|
||||
3. **Vibe-Hit-Rate** — Stimmen Vibes (good/bad) mit objektiven Folge-Daten
|
||||
überein?
|
||||
|
||||
### Living Oracle (das Killer-Feature)
|
||||
Bei der Erfassung eines neuen Zeichens prüft Mana **deine eigene Historie**
|
||||
und gibt — wo statistisch belastbar — eine Living-Oracle-Reflektion aus:
|
||||
|
||||
> *Du hast 4× zuvor einen Wassertraum protokolliert. Danach im Schnitt:
|
||||
> 38min weniger Schlaf, leicht angespannte Stimmung am Folgetag. Vielleicht
|
||||
> heute keine schweren Termine?*
|
||||
|
||||
Das ist Empirismus mit dem Mantel der Wahrsagerei. Implementation:
|
||||
deterministisch, kein LLM-Halluzinations-Risiko (LLM nur für Phrasing,
|
||||
nicht für Inferenz).
|
||||
|
||||
## Modul-Struktur
|
||||
|
||||
```
|
||||
apps/mana/apps/web/src/lib/modules/augur/
|
||||
├── types.ts # LocalAugurEntry, AugurKind, Vibe, Outcome
|
||||
├── collections.ts # augurEntries-Table + Guest-Seed (1 Omen, 1 Fortune, 1 Hunch)
|
||||
├── queries.ts # useAllEntries, useEntriesByKind, useUnresolved, useDueForReveal, useStats
|
||||
├── stores/
|
||||
│ └── entries.svelte.ts # createEntry, updateEntry, resolveEntry, archiveEntry
|
||||
├── components/
|
||||
│ ├── EntryCard.svelte # vibe-colored Karte (Galerie-Item)
|
||||
│ ├── EntryForm.svelte # Capture-UI (eine Form, Felder ändern sich pro kind)
|
||||
│ ├── VibeBadge.svelte # good/bad/mysterious
|
||||
│ ├── OutcomeBadge.svelte # fulfilled / partly / not / open
|
||||
│ ├── ResolveDialog.svelte # "Hat sich das bewahrheitet?" – kommt per PN/Inbox
|
||||
│ ├── LivingOracleHint.svelte # die deterministische Reflektion bei Capture
|
||||
│ └── KindTabs.svelte # Alle | Omen | Fortune | Hunch
|
||||
├── views/
|
||||
│ ├── WitnessView.svelte # Default-Surface — vibe-Galerie
|
||||
│ ├── OracleView.svelte # Calibration + Correlation + Vibe-Hit-Rate
|
||||
│ ├── DetailView.svelte # Einzelansicht inkl. Reflektion + Resolve-Action
|
||||
│ └── YearRecapView.svelte # AI-erzählter Jahresrückblick
|
||||
├── lib/
|
||||
│ ├── correlation-engine.ts # Cross-Module-Korrelations-Berechnung
|
||||
│ ├── calibration.ts # Brier-Score, Hit-Rate pro Source
|
||||
│ └── living-oracle.ts # Match neue Eingabe gegen Historie + Folge-Daten
|
||||
├── tools.ts # MCP-Tools (M5)
|
||||
├── constants.ts # KIND_LABELS, VIBE_LABELS, DEFAULT_SOURCES
|
||||
├── ListView.svelte # Modul-Root — switched zwischen WitnessView und OracleView
|
||||
├── module.config.ts # { appId: 'augur', tables: [{ name: 'augurEntries' }] }
|
||||
└── index.ts # Re-Exports
|
||||
```
|
||||
|
||||
## Daten-Schema
|
||||
|
||||
### `LocalAugurEntry` (Dexie)
|
||||
|
||||
```typescript
|
||||
export type AugurKind = 'omen' | 'fortune' | 'hunch';
|
||||
|
||||
export type AugurVibe = 'good' | 'bad' | 'mysterious';
|
||||
|
||||
export type AugurOutcome = 'fulfilled' | 'partly' | 'not-fulfilled' | 'open';
|
||||
|
||||
export interface LocalAugurEntry extends BaseRecord {
|
||||
kind: AugurKind; // plaintext — Discriminator, filterbar
|
||||
source: string; // encrypted — "schwarze Katze", "Glückskeks", "Bauchgefühl", "Mutter"
|
||||
sourceCategory?: string | null; // plaintext — "tarot" | "horoscope" | "fortune-cookie" | "gut" | "person" | "media" | "natural" | ... — für Calibration-per-Source
|
||||
claim: string; // encrypted — was das Zeichen zu sagen schien
|
||||
vibe: AugurVibe; // plaintext — primärer Filter in WitnessView
|
||||
feltMeaning?: string | null; // encrypted — "soll ich den Job nicht annehmen"
|
||||
expectedOutcome?: string | null; // encrypted — konkrete Prognose, falls erfasst
|
||||
expectedBy?: string | null; // plaintext ISO-Datum — triggert Resolve-Reminder
|
||||
probability?: number | null; // plaintext — 0..1, optional (nur power-user)
|
||||
outcome: AugurOutcome; // plaintext — startet 'open', kommt bei Resolve
|
||||
outcomeNote?: string | null; // encrypted — wie genau ist es gekommen
|
||||
resolvedAt?: string | null; // plaintext
|
||||
encounteredAt: string; // plaintext ISO-Datum — wann das Zeichen kam
|
||||
tags: string[]; // encrypted
|
||||
relatedDreamId?: string | null; // plaintext — Cross-Link in dreams-Modul
|
||||
relatedDecisionId?: string | null; // plaintext — Cross-Link in decisions (falls existiert)
|
||||
livingOracleSnapshot?: string | null; // encrypted — die Reflektion zur Erfass-Zeit (für Audit)
|
||||
isPrivate: boolean; // plaintext — von ZK-Default abweichen erlaubt
|
||||
}
|
||||
```
|
||||
|
||||
### Verschlüsselung
|
||||
|
||||
Standardmäßig **encrypted** (siehe `apps/mana/apps/web/src/lib/data/crypto/registry.ts`):
|
||||
`source`, `claim`, `feltMeaning`, `expectedOutcome`, `outcomeNote`, `tags`,
|
||||
`livingOracleSnapshot`. Plaintext bleibt nur, was für Filter / Korrelation /
|
||||
Reminder-Scheduling nötig ist (`kind`, `vibe`, `outcome`, `sourceCategory`,
|
||||
Daten, IDs).
|
||||
|
||||
Visibility-Default (vgl. `@mana/shared-privacy`): **`private`**. Embed-fähig
|
||||
nur über explizite Hochsetzung pro Eintrag. Begründung: Auch ein "harmloses"
|
||||
Bauchgefühl kann sehr persönlich sein; keine Default-Sichtbarkeit nach außen.
|
||||
|
||||
## Living Oracle — Algorithmus
|
||||
|
||||
Beim `createEntry` (oder synchron im Background-Tick mit Render-Hint):
|
||||
|
||||
1. Fingerprint des neuen Eintrags bilden:
|
||||
`{ kind, sourceCategory, vibe, tagSet, derived: keywords-from-claim }`
|
||||
2. Suche in eigener Historie (`augurEntries`) nach Einträgen mit
|
||||
≥2 übereinstimmenden Fingerprint-Komponenten **und** `outcome != 'open'`.
|
||||
3. Für jeden Treffer: lies aus `mood`, `sleep`, `body` die Werte des
|
||||
Folge-Tags (`encounteredAt + 1d` bis `+3d`).
|
||||
4. Mittelwert + Stichprobengröße + einseitiger Vorzeichentest (oder
|
||||
Bootstrap-CI). Schwelle: `n ≥ 3` und Effekt klar (Δmood ≥ 0.5σ ODER
|
||||
Δsleep ≥ 20min) → Living-Oracle-Hint zeigen.
|
||||
5. LLM (lokal über `@mana/local-llm` oder mana-llm) übernimmt **nur**
|
||||
das Phrasing — Eingabe ist die strukturierte Statistik, nicht die
|
||||
Rohdaten. Kein Halluzinations-Risiko bei numerischen Werten.
|
||||
|
||||
Auditierbarkeit: `livingOracleSnapshot` speichert die Reflektion zum
|
||||
Erfass-Zeitpunkt (verschlüsselt). Bei Resolve sieht der Nutzer
|
||||
"das Orakel sagte damals X — und es trat Y ein".
|
||||
|
||||
Cold-Start: vor 50 Einträgen wird kein Living-Oracle-Hint gezeigt.
|
||||
Vorher Empty-State im OracleView: *"Noch zu früh — sammle erst Zeichen."*
|
||||
|
||||
## Routing
|
||||
|
||||
```
|
||||
/augur → WitnessView (Default)
|
||||
/augur?mode=oracle → OracleView
|
||||
/augur/entry/[id] → DetailView
|
||||
/augur/recap/[year] → YearRecapView
|
||||
```
|
||||
|
||||
Keine separaten Routen pro `kind` — Filter via `KindTabs` in der View.
|
||||
|
||||
## Inbox-Integration
|
||||
|
||||
Reminder-Strategie für Resolve:
|
||||
|
||||
- Bei `expectedBy` gesetzt → Reminder am Folgetag der Deadline
|
||||
- Bei `expectedBy` nicht gesetzt → Default-Reminder nach 30 Tagen
|
||||
- Reminder läuft über `myday`/Inbox-Mechanik (nicht eigener Pusher),
|
||||
Action öffnet `ResolveDialog`
|
||||
|
||||
## Cross-Modul-Hooks
|
||||
|
||||
- **`dreams`**: in DreamDetailView ein "→ als Omen markieren"-Button, der
|
||||
`augurEntries` mit `relatedDreamId` befüllt
|
||||
- **`decisions`** (falls gebaut): "Hattest du ein Bauchgefühl?"-Quick-Add
|
||||
öffnet `augur` Capture mit `kind=hunch` + `relatedDecisionId`
|
||||
- **`flashbacks`** (falls gebaut): augur-Einträge erscheinen im "Vor X
|
||||
Jahren"-Stream wie andere Module
|
||||
|
||||
## AI-Integration (M5)
|
||||
|
||||
MCP-Tools in `tools.ts` (Pattern wie in `comic`/`writing`):
|
||||
|
||||
- `augur.captureSign({ kind, source, claim, vibe, ... })` — schneller
|
||||
Capture für Personas / Voice-Bot
|
||||
- `augur.consultOracle({ contextHint? })` — gibt eine empirisch fundierte
|
||||
Reflektion (Living-Oracle-Logik) zurück, optional mit Kontext-Hint
|
||||
- `augur.resolveEntry({ entryId, outcome, note })`
|
||||
- `augur.yearRecap({ year })` — strukturierter Year-Recap (Quelle für die
|
||||
AI-erzählte View)
|
||||
|
||||
Persona-Kandidat: **"Die Augurin"** als Mana-Persona — neutral-skeptische
|
||||
Beraterin, die Living-Oracle-Hints in poetische Sprache übersetzt. Optional
|
||||
in `M5`.
|
||||
|
||||
## Visibility & Embed
|
||||
|
||||
Visibility-System voll integriert (vgl. `@mana/shared-privacy`):
|
||||
|
||||
- Default `private`
|
||||
- Pro Eintrag hochsetzbar bis `unlisted`
|
||||
- Embed-Quelle "Augur" für Website-Builder: Galerie der "good"-vibe Omen
|
||||
mit Outcome `fulfilled`, anonymisiert auf Wunsch
|
||||
|
||||
Erst in **M6** — Visibility-System ist Modul-für-Modul Rollout (siehe
|
||||
Memory-Eintrag *Visibility-System — M1–M5.c shipped*).
|
||||
|
||||
## Encryption-Registry
|
||||
|
||||
Eintrag in `apps/mana/apps/web/src/lib/data/crypto/registry.ts`:
|
||||
|
||||
```typescript
|
||||
augurEntries: {
|
||||
encryptedFields: ['source', 'claim', 'feltMeaning', 'expectedOutcome',
|
||||
'outcomeNote', 'tags', 'livingOracleSnapshot'],
|
||||
plaintextFields: ['kind', 'vibe', 'outcome', 'sourceCategory',
|
||||
'encounteredAt', 'expectedBy', 'resolvedAt',
|
||||
'probability', 'relatedDreamId', 'relatedDecisionId',
|
||||
'isPrivate'],
|
||||
}
|
||||
```
|
||||
|
||||
## Meilensteine
|
||||
|
||||
### M1 — Skelett (1 Tag)
|
||||
- `module.config.ts` + Registry-Eintrag in `module-registry.ts`
|
||||
- Dexie-Schema-Bump in `database.ts` (neue Tabelle `augurEntries`)
|
||||
- Encryption-Registry: Felder eintragen (verpflichtend bei Sensible-Defaults)
|
||||
- Route `apps/mana/apps/web/src/routes/(app)/augur/` mit Platzhalter
|
||||
- App-Eintrag in `packages/shared-branding/src/mana-apps.ts` (Icon, Tier,
|
||||
Branding)
|
||||
- Guest-Seed: 3 Beispiel-Einträge (1 Omen, 1 Fortune, 1 Hunch — alle mit
|
||||
Outcome um sofort etwas in der Galerie zu zeigen)
|
||||
- soft-first Migration (vgl. Feedback-Memory): erst lesen-tolerant, dann
|
||||
Hard-Pass
|
||||
|
||||
### M2 — Capture + Galerie (Witness-Modus, 2 Tage)
|
||||
- `EntryForm.svelte` mit kind-Tabs in der Form (Felder ändern sich)
|
||||
- `EntryCard.svelte` + `WitnessView.svelte` als Galerie
|
||||
- `KindTabs.svelte` für Filter
|
||||
- `DetailView.svelte` mit Resolve-Action
|
||||
- `ResolveDialog.svelte` (kann auch direkt im Detail laufen)
|
||||
- Stores: `createEntry`, `updateEntry`, `resolveEntry`, `archiveEntry`
|
||||
|
||||
### M3 — Resolve-Reminder (1 Tag)
|
||||
- Inbox-Integration: bei `expectedBy` Resolve-Reminder erzeugen, Default
|
||||
30-Tage-Fallback
|
||||
- Query `useDueForReveal` für eine "fällig"-Liste in der View
|
||||
- Push-Notification optional (über bestehende mana-notify-Pipeline)
|
||||
|
||||
### M4 — Oracle-Modus (3 Tage)
|
||||
- `OracleView.svelte` mit drei Sektionen:
|
||||
- **Calibration per Source** — Brier-Score / Hit-Rate Tabelle
|
||||
- **Correlation Matrix** — Cross-Module-Engine in `correlation-engine.ts`
|
||||
- **Vibe-Hit-Rate** — Mood/Sleep-Folge-Daten vs. Vibe
|
||||
- `lib/calibration.ts` mit deterministischer Brier-Berechnung
|
||||
- Cold-Start-Empty-State unter 20 resolvten Einträgen
|
||||
|
||||
### M4.5 — Living Oracle (2 Tage)
|
||||
- `lib/living-oracle.ts` — Fingerprint + Historien-Match + Stat-Test
|
||||
- `LivingOracleHint.svelte` — UI-Block in `EntryForm` after-create
|
||||
- `livingOracleSnapshot` befüllen + bei Resolve neben Outcome anzeigen
|
||||
- Cold-Start unter 50 Einträgen → Hint deaktiviert
|
||||
- LLM-Phrasing optional: ohne LLM nüchterner Fakten-Block, mit LLM
|
||||
poetischere Formulierung; beides funktioniert
|
||||
|
||||
### M5 — MCP-Tools + Persona (1 Tag)
|
||||
- `tools.ts` mit `captureSign`, `consultOracle`, `resolveEntry`,
|
||||
`yearRecap`
|
||||
- AI-Tool-Catalog Bridge (vgl. comic-Modul-Pattern)
|
||||
- Optional: "Die Augurin" Persona-Definition als Seed
|
||||
|
||||
### M6 — Year-Recap + Visibility (2 Tage)
|
||||
- `YearRecapView.svelte` — AI-erzählter Jahresrückblick mit
|
||||
Living-Oracle-Highlights
|
||||
- Visibility-Felder in Form + Galerie-Filter
|
||||
- Embed-Quelle für Website-Builder
|
||||
|
||||
### M7 (optional) — Cross-Modul-Hooks
|
||||
- "→ als Omen markieren" in `dreams`
|
||||
- Bauchgefühl-Quick-Add in `decisions`
|
||||
- Aufnahme in `flashbacks`-Stream
|
||||
|
||||
### M8 (optional) — Voice-First Capture
|
||||
- Voice-Bot-Integration: "Hey Mana, ich hab ein Bauchgefühl, dass…" →
|
||||
STT → `augur.captureSign` über MCP
|
||||
- Niedrige Capture-Hürde wäre exakt das, was für Hunches fehlt
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit-Tests für `calibration.ts` (Brier-Score-Korrektheit)
|
||||
- Unit-Tests für `correlation-engine.ts` mit Fixtures aus
|
||||
`mood`/`sleep`-Mock-Daten
|
||||
- Unit-Tests für `living-oracle.ts` Fingerprint-Match-Logik
|
||||
- Vitest mit Mock-Factories aus `.claude/guidelines/testing.md`
|
||||
- Snapshot-Test für Year-Recap-JSON-Struktur (vor LLM-Phrasing)
|
||||
|
||||
## Risiken & offene Fragen
|
||||
|
||||
- **Kalibrierungs-Datenmenge**: bis 50 Einträge ist OracleView fast leer.
|
||||
Onboarding muss klar machen *"erst sammeln, dann auswerten"*.
|
||||
- **Subjektivität von Outcome**: "ist das Bauchgefühl eingetreten?" ist
|
||||
selten klar Ja/Nein. `partly` als first-class citizen ist Pflicht;
|
||||
`outcomeNote` für Nuance.
|
||||
- **LLM-Halluzinations-Risiko bei Living Oracle**: durch deterministische
|
||||
Stat-Computation + LLM-only-for-phrasing entschärft, aber muss in
|
||||
Tests verteidigt werden (LLM darf keine Zahlen ändern).
|
||||
- **Esoterik-Wahrnehmung**: das Modul ist *empirisch*, aber das Branding
|
||||
schreit "Wahrsagerei". Tonalität in Texten muss klar "wir messen, wir
|
||||
spekulieren nicht" kommunizieren. UI darf trotzdem schön sein.
|
||||
- **Cross-Modul-Korrelationen brauchen Plaintext-Felder dort**: `mood`
|
||||
speichert Mood-Werte plaintext (verifizieren), `sleep` Schlafdauer
|
||||
plaintext. Falls verschlüsselt, muss korrelation-engine durch
|
||||
decryption-Layer — Aufwand verdoppelt sich.
|
||||
|
||||
## Naming-Begründung
|
||||
|
||||
`augur` (lat. *augurium*, "Vogel-Beobachtung") ist der römische Priester,
|
||||
der Zeichen liest. Ein-Wort-Name, neutraler Ton (weder zu woo noch zu
|
||||
trocken), passt zur Modul-Konvention (`firsts`, `dreams`, `quotes`),
|
||||
und der Nutzer wird zum Augur seines eigenen Lebens — was die
|
||||
Self-Empowerment-Botschaft ist.
|
||||
|
||||
Alternativen erwogen: `signs` (zu generisch, Konflikt mit UI-Konzepten),
|
||||
`omens` (deckt nur 1/3 der Inhalte ab), `oracle` (klingt zu sehr nach
|
||||
externer Wahrsagerei).
|
||||
|
||||
## Nicht im Scope
|
||||
|
||||
- Externe Tarot-Decks / Horoskop-APIs für Auto-Captures (kann später als
|
||||
`mana-research` Provider hinzukommen)
|
||||
- Aggregierte/anonymisierte Auswertungen über Nutzer hinweg ("im Schnitt
|
||||
haben unsere Nutzer X% Bauchgefühl-Trefferquote") — Datenschutz-Risiko,
|
||||
Mana ist private-by-default
|
||||
- Spirituelle/religiöse Inhalte (Gebet, Meditation): bleiben in `meditate`
|
||||
- Predictions-Markets / monetäre Wetten: bleiben im (geplanten) `bets`
|
||||
|
|
@ -1987,6 +1987,210 @@ export const AI_TOOL_CATALOG: readonly ToolSchema[] = [
|
|||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── Augur (signs / fortunes / hunches) ──────────────────────
|
||||
{
|
||||
name: 'capture_sign',
|
||||
module: 'augur',
|
||||
description:
|
||||
'Erfasst ein Zeichen (Omen, Wahrsagung oder Bauchgefuehl) im Augur-Modul. Standardmaessig Stimmung "mysterious" wenn nicht angegeben. Gibt die ID zurueck.',
|
||||
defaultPolicy: 'propose',
|
||||
parameters: [
|
||||
{
|
||||
name: 'kind',
|
||||
type: 'string',
|
||||
description: 'Art des Zeichens',
|
||||
required: true,
|
||||
enum: ['omen', 'fortune', 'hunch'],
|
||||
},
|
||||
{
|
||||
name: 'source',
|
||||
type: 'string',
|
||||
description: 'Quelle (z.B. "schwarze Katze", "Glueckskeks", "Bauchgefuehl")',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'claim',
|
||||
type: 'string',
|
||||
description: 'Was das Zeichen aussagt',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'sourceCategory',
|
||||
type: 'string',
|
||||
description: 'Quellenkategorie',
|
||||
required: false,
|
||||
enum: [
|
||||
'gut',
|
||||
'tarot',
|
||||
'horoscope',
|
||||
'fortune-cookie',
|
||||
'iching',
|
||||
'dream',
|
||||
'person',
|
||||
'media',
|
||||
'natural',
|
||||
'other',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'vibe',
|
||||
type: 'string',
|
||||
description: 'Grundstimmung des Zeichens',
|
||||
required: false,
|
||||
enum: ['good', 'bad', 'mysterious'],
|
||||
},
|
||||
{
|
||||
name: 'feltMeaning',
|
||||
type: 'string',
|
||||
description: 'Eigene Deutung (optional)',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'expectedOutcome',
|
||||
type: 'string',
|
||||
description: 'Konkrete Prognose (optional)',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'expectedBy',
|
||||
type: 'string',
|
||||
description: 'Bis wann sollte sich zeigen ob es eintritt (YYYY-MM-DD)',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'probability',
|
||||
type: 'number',
|
||||
description: 'Wahrscheinlichkeit 0..1 (optional)',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'tags',
|
||||
type: 'string',
|
||||
description: 'Tags durch Komma getrennt',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'resolve_sign',
|
||||
module: 'augur',
|
||||
description:
|
||||
'Loest ein offenes Zeichen auf — markiert ob es eingetreten ist (fulfilled / partly / not-fulfilled) und kann eine Notiz speichern.',
|
||||
defaultPolicy: 'propose',
|
||||
parameters: [
|
||||
{ name: 'entryId', type: 'string', description: 'ID des Zeichens', required: true },
|
||||
{
|
||||
name: 'outcome',
|
||||
type: 'string',
|
||||
description: 'Ergebnis',
|
||||
required: true,
|
||||
enum: ['fulfilled', 'partly', 'not-fulfilled'],
|
||||
},
|
||||
{
|
||||
name: 'note',
|
||||
type: 'string',
|
||||
description: 'Optionale Notiz wie es kam',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'list_open_signs',
|
||||
module: 'augur',
|
||||
description:
|
||||
'Listet noch offene Zeichen — id, kind, source, claim, encounteredAt, expectedBy. Optional gefiltert nach kind.',
|
||||
defaultPolicy: 'auto',
|
||||
parameters: [
|
||||
{
|
||||
name: 'kind',
|
||||
type: 'string',
|
||||
description: 'Nur eine Art zeigen',
|
||||
required: false,
|
||||
enum: ['omen', 'fortune', 'hunch'],
|
||||
},
|
||||
{
|
||||
name: 'limit',
|
||||
type: 'number',
|
||||
description: 'Maximale Anzahl (Standard 30)',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'consult_oracle',
|
||||
module: 'augur',
|
||||
description:
|
||||
'Befragt das Living Oracle: nimmt eine Sign-Beschreibung und gibt zurueck was bei aehnlichen Zeichen in der Vergangenheit geschah (n, hit-rate, breakdown). Schweigt unter 50 aufgeloesten Eintraegen oder unter 3 Treffern (cold-start).',
|
||||
defaultPolicy: 'auto',
|
||||
parameters: [
|
||||
{
|
||||
name: 'kind',
|
||||
type: 'string',
|
||||
description: 'Art des hypothetischen Zeichens',
|
||||
required: true,
|
||||
enum: ['omen', 'fortune', 'hunch'],
|
||||
},
|
||||
{
|
||||
name: 'sourceCategory',
|
||||
type: 'string',
|
||||
description: 'Quellenkategorie',
|
||||
required: true,
|
||||
enum: [
|
||||
'gut',
|
||||
'tarot',
|
||||
'horoscope',
|
||||
'fortune-cookie',
|
||||
'iching',
|
||||
'dream',
|
||||
'person',
|
||||
'media',
|
||||
'natural',
|
||||
'other',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'vibe',
|
||||
type: 'string',
|
||||
description: 'Grundstimmung',
|
||||
required: true,
|
||||
enum: ['good', 'bad', 'mysterious'],
|
||||
},
|
||||
{
|
||||
name: 'source',
|
||||
type: 'string',
|
||||
description: 'Quellen-Stichwort fuer Keyword-Matching',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'claim',
|
||||
type: 'string',
|
||||
description: 'Aussage fuer Keyword-Matching',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'tags',
|
||||
type: 'string',
|
||||
description: 'Tags durch Komma getrennt',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'augur_year_recap',
|
||||
module: 'augur',
|
||||
description:
|
||||
'Strukturierter Jahresrueckblick: total / aufgeloest / hit-rate / vibe-breakdown / top-source-categories. Year als YYYY (Standard: aktuelles Jahr).',
|
||||
defaultPolicy: 'auto',
|
||||
parameters: [
|
||||
{
|
||||
name: 'year',
|
||||
type: 'number',
|
||||
description: 'Jahr (z.B. 2026). Standard: aktuelles Jahr.',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
|
|
|||
|
|
@ -86,6 +86,13 @@ const comicSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="
|
|||
// reads as "clothing" at any scale.
|
||||
const wardrobeSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#wardrobeGrad)"/><path d="M512 246c-34 0-62 28-62 62 0 15 6 28 15 37l-113 58c-14 7-22 22-22 37v40l-40 28c-10 7-13 20-8 31l20 40c5 10 16 16 27 13l46-12v256c0 18 14 32 32 32h250c18 0 32-14 32-32V580l46 12c11 3 22-3 27-13l20-40c5-11 2-24-8-31l-40-28v-40c0-15-8-30-22-37l-113-58c9-9 15-22 15-37 0-34-28-62-62-62zm0 44c18 0 32 14 32 32s-14 32-32 32-32-14-32-32 14-32 32-32z" fill="white"/><path d="M420 450c0 50 41 90 92 90s92-40 92-90" stroke="#be185d" stroke-width="6" stroke-linecap="round" fill="none" stroke-opacity="0.25"/><defs><linearGradient id="wardrobeGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#e11d48"/><stop offset="1" stop-color="#a21caf"/></linearGradient></defs></svg>`;
|
||||
|
||||
// Augur icon — open eye with a small star in the iris and three drifting
|
||||
// dots ("signs in the air") on indigo→violet gradient. Sits in the cosmic
|
||||
// family next to Dreams (indigo) and Cards (violet) so the launcher reads
|
||||
// as "the seeing/oracular cluster". The eye is symmetric and abstract on
|
||||
// purpose: not a religious or zodiac symbol, just "watch".
|
||||
const augurSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#augurGrad)"/><path d="M260 512c0 0 112-148 252-148s252 148 252 148-112 148-252 148S260 512 260 512z" fill="white"/><circle cx="512" cy="512" r="86" fill="#4338ca"/><circle cx="512" cy="512" r="34" fill="#0f0a3d"/><path d="M512 470l8 26h27l-22 16 8 26-21-16-21 16 8-26-22-16h27z" fill="white"/><circle cx="320" cy="320" r="14" fill="white" fill-opacity="0.55"/><circle cx="700" cy="290" r="10" fill="white" fill-opacity="0.45"/><circle cx="740" cy="720" r="12" fill="white" fill-opacity="0.5"/><circle cx="290" cy="700" r="8" fill="white" fill-opacity="0.4"/><defs><linearGradient id="augurGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#4338ca"/><stop offset="1" stop-color="#7c3aed"/></linearGradient></defs></svg>`;
|
||||
|
||||
/**
|
||||
* App icons as data URLs
|
||||
* Use these directly in <img src={APP_ICONS.memoro}> or CSS background-image
|
||||
|
|
@ -115,6 +122,7 @@ export const APP_ICONS = {
|
|||
inventory: svgToDataUrl(inventorySvg),
|
||||
wardrobe: svgToDataUrl(wardrobeSvg),
|
||||
comic: svgToDataUrl(comicSvg),
|
||||
augur: svgToDataUrl(augurSvg),
|
||||
questions: svgToDataUrl(questionsSvg),
|
||||
context: svgToDataUrl(contextSvg),
|
||||
citycorners: svgToDataUrl(citycornersSvg),
|
||||
|
|
|
|||
|
|
@ -1173,6 +1173,23 @@ export const MANA_APPS: ManaApp[] = [
|
|||
status: 'development',
|
||||
requiredTier: 'public',
|
||||
},
|
||||
{
|
||||
id: 'augur',
|
||||
name: 'Augur',
|
||||
description: {
|
||||
de: 'Zeichen sammeln, Muster lesen',
|
||||
en: 'Collect signs, read patterns',
|
||||
},
|
||||
longDescription: {
|
||||
de: 'Halte Omen, Wahrsagungen und Bauchgefühle fest — und lass Mana mit der Zeit zeigen, welche deiner inneren Stimmen wirklich Recht behalten. Witness-Modus für poetisches Erfassen, Oracle-Modus für ehrliche Auswertung.',
|
||||
en: 'Capture omens, fortunes, and hunches — and over time let Mana show which of your inner voices actually get it right. Witness mode for poetic capture, Oracle mode for honest evaluation.',
|
||||
},
|
||||
icon: APP_ICONS.augur,
|
||||
color: '#7c3aed',
|
||||
comingSoon: false,
|
||||
status: 'development',
|
||||
requiredTier: 'guest',
|
||||
},
|
||||
{
|
||||
id: 'spaces',
|
||||
name: 'Spaces',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue