Files
FabledGrimoire/frontend/src/components/MomentProposals.vue
T
bvandeusenandClaude Opus 5.5 bdd803537b
CI & Build / Python lint (push) Successful in 9s
CI & Build / Plugin hooks (push) Successful in 22s
CI & Build / TypeScript typecheck (push) Successful in 1m0s
CI & Build / integration (push) Successful in 1m55s
CI & Build / Python tests (push) Successful in 2m20s
CI & Build / Build & push image (push) Successful in 1m7s
docs(6208): the remaining frontend comments drop milestone, step and rule citations
Components, stores, api clients, router, types and shared CSS now state what
each piece is and why, without the milestone that introduced it. diff.ts
names its consumers instead of the copy history; stores/notes and tasks state
their current scope instead of the cleanup that set it; theme.css states the
token practice instead of the sweep that established it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:14:03 -04:00

191 lines
7.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { getProposals, judgeProposals } from "@/api/moments";
import type { MomentProposal, ProposalsPayload, RuleProposals, Verdict } from "@/api/moments";
import { apiErrorMessage } from "@/api/client";
import { useMomentsStore } from "@/stores/moments";
import { useToastStore } from "@/stores/toast";
/**
* Proposals about a rule's moments, waiting on a person. A mount proposal
* comes from a pass that read the rule, or from the
* rule being opened just after the moment fired in several sessions; nothing
* is mounted until "Mount" is pressed. An unmount proposal comes
* from sessions reporting that the mount arrived where it did not apply;
* nothing is taken off until "Take it off" is pressed. Both are the judgment
* `judge_rule_moments` makes in a session, through the same service.
*/
const store = useMomentsStore();
const toast = useToastStore();
const data = ref<ProposalsPayload | null>(null);
const failed = ref(false);
const busy = ref<string | null>(null);
const rules = computed<RuleProposals[]>(() => data.value?.rules ?? []);
async function load() {
try {
data.value = await getProposals();
failed.value = false;
} catch {
failed.value = true;
}
}
function key(rule: RuleProposals, p: MomentProposal): string {
return `${rule.id}:${p.moment}`;
}
function plural(n: number, one: string, many: string): string {
return `${n} ${n === 1 ? one : many}`;
}
function sourceLabel(p: MomentProposal): string {
if (p.proposal === "unmount") {
return `reported as not applying here on ${plural(p.evidence.situations, "day", "days")}`;
}
if (p.source === "signal") {
return `opened just after this moment in ${p.evidence.situations} sessions`;
}
return "proposed from the rule's text";
}
// What the action that reached the moment was, most frequent first — so the
// operator can see whether it is the rule or one action that keeps misfiring.
function actions(p: MomentProposal): string[] {
return Object.entries(p.reached_by ?? {})
.sort((a, b) => b[1] - a[1])
.map(([action, n]) => `${action} ×${n}`);
}
function note(p: MomentProposal, verdict: Verdict): string {
if (p.proposal === "unmount") {
return verdict === "confirm" ? "Kept in Settings." : "Taken off in Settings.";
}
return verdict === "confirm" ? "Mounted in Settings." : "Declined in Settings.";
}
async function decide(rule: RuleProposals, p: MomentProposal, verdict: Verdict) {
busy.value = key(rule, p);
try {
const out = await judgeProposals([{
rule_id: rule.id, moment: p.moment, verdict, note: note(p, verdict),
}]);
if (out.refused.length) {
toast.show(out.refused[0].error, "error");
return;
}
await load();
// A mount or an unmount changes the per-moment counts the list below shows.
if ((p.proposal === "unmount") === (verdict === "reject")) await store.load(true);
} catch (e) {
toast.show(apiErrorMessage(e, "Could not record that"), "error");
} finally {
busy.value = null;
}
}
onMounted(load);
</script>
<template>
<section v-if="failed || rules.length" class="proposals" aria-labelledby="proposals-title">
<h3 id="proposals-title" class="sub-title">Waiting on you</h3>
<p v-if="failed" class="error-msg">
The proposals could not be loaded.
<button type="button" class="btn-text" @click="load">Try again</button>
</p>
<template v-else>
<p class="field-hint">
Moments these rules may belong on, or may not. Mounting one makes the rule arrive
whenever that moment happens, whatever the work is about; declining keeps it from being
proposed again. A mount that sessions keep reporting as beside the point can be taken
off — or, when one action is reaching the moment wrongly, unmapped in the list below.
</p>
<ul class="proposal-list">
<li v-for="rule in rules" :key="rule.id" class="proposal-rule">
<div class="rule-head">
<span class="rule-title">#{{ rule.id }} {{ rule.title }}</span>
<span v-if="rule.mounted.length" class="rule-mounted">
on <code v-for="m in rule.mounted" :key="m" class="moment-name">{{ m }}</code>
</span>
</div>
<p class="rule-statement">{{ rule.statement }}</p>
<ul class="proposal-items">
<li v-for="p in rule.proposals" :key="key(rule, p)" class="proposal-item">
<span v-if="p.proposal === 'unmount'" class="proposal-kind">take off</span>
<code class="moment-name">{{ p.moment }}</code>
<span class="proposal-why">
{{ p.why || sourceLabel(p) }}
<span v-if="p.why" class="proposal-source">· {{ sourceLabel(p) }}</span>
<span v-if="actions(p).length" class="proposal-source">
· reached by {{ actions(p).join(", ") }}
</span>
</span>
<span v-if="p.proposal === 'unmount'" class="proposal-actions">
<button
type="button" class="btn-primary btn-sm"
:disabled="busy !== null"
@click="decide(rule, p, 'reject')"
>Take it off</button>
<button
type="button" class="btn-text"
:disabled="busy !== null"
@click="decide(rule, p, 'confirm')"
>Keep it</button>
</span>
<span v-else class="proposal-actions">
<button
type="button" class="btn-primary btn-sm"
:disabled="busy !== null"
@click="decide(rule, p, 'confirm')"
>Mount</button>
<button
type="button" class="btn-text"
:disabled="busy !== null"
@click="decide(rule, p, 'reject')"
>Not this</button>
</span>
<ul v-if="p.reasons && p.reasons.length > 1" class="proposal-reasons">
<li v-for="r in p.reasons.slice(0, -1).reverse()" :key="r.at">{{ r.why }}</li>
</ul>
</li>
</ul>
</li>
</ul>
</template>
</section>
</template>
<style scoped>
.proposals { display: flex; flex-direction: column; gap: var(--fs-space-2); }
.sub-title { margin: 0; font-size: 0.95rem; color: var(--fs-text-primary); }
.proposal-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-2); }
.proposal-rule {
padding: var(--fs-space-3);
background: var(--fs-surface-page);
border: 1px dashed var(--fs-border-color);
border-radius: var(--fs-radius-md);
}
.rule-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--fs-space-2); }
.rule-title { font-size: var(--fs-size-body-sm); color: var(--fs-text-primary); font-weight: var(--fs-weight-medium); }
.rule-mounted { display: inline-flex; flex-wrap: wrap; gap: var(--fs-space-1); font-size: var(--fs-size-tiny); color: var(--fs-text-tertiary); }
.rule-statement { margin: var(--fs-space-1) 0 var(--fs-space-2); font-size: var(--fs-size-body-sm); color: var(--fs-text-secondary); line-height: var(--fs-leading-body); }
.proposal-items { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-2); }
.proposal-item { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--fs-space-2); font-size: var(--fs-size-body-sm); }
.proposal-why { flex: 1 1 16rem; color: var(--fs-text-secondary); }
.proposal-source { color: var(--fs-text-tertiary); font-size: var(--fs-size-tiny); }
.proposal-actions { display: inline-flex; gap: var(--fs-space-2); margin-left: auto; }
.proposal-kind { font-size: var(--fs-size-tiny); color: var(--fs-text-tertiary); }
.proposal-reasons {
flex-basis: 100%;
margin: 0;
padding-left: var(--fs-space-4);
font-size: var(--fs-size-tiny);
color: var(--fs-text-tertiary);
}
</style>
<style src="@/assets/moments-shared.css" />