mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue