Add Control Plane bubble help and step-by-step guidance
Context ? help popovers, lifecycle checklist, and recommended-next-step cards on login, dashboard, phase detail/new, and extensions. Pure guidance helpers + CSS; deploy image 0.1.4.
This commit is contained in:
parent
8cf124530c
commit
f1109d54ee
14 changed files with 1031 additions and 65 deletions
|
|
@ -30,7 +30,7 @@ spec:
|
||||||
fsGroup: 10001
|
fsGroup: 10001
|
||||||
containers:
|
containers:
|
||||||
- name: bootstrap
|
- name: bootstrap
|
||||||
image: forgejo.coulomb.social/coulomb/target-revenue:0.1.3
|
image: forgejo.coulomb.social/coulomb/target-revenue:0.1.4
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
env:
|
env:
|
||||||
- name: TRF_BOOTSTRAP_BINKY
|
- name: TRF_BOOTSTRAP_BINKY
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ spec:
|
||||||
fsGroup: 10001
|
fsGroup: 10001
|
||||||
containers:
|
containers:
|
||||||
- name: target-revenue
|
- name: target-revenue
|
||||||
image: forgejo.coulomb.social/coulomb/target-revenue:0.1.3
|
image: forgejo.coulomb.social/coulomb/target-revenue:0.1.4
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
ports:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ spec:
|
||||||
fsGroup: 10001
|
fsGroup: 10001
|
||||||
containers:
|
containers:
|
||||||
- name: migrate
|
- name: migrate
|
||||||
image: forgejo.coulomb.social/coulomb/target-revenue:0.1.3
|
image: forgejo.coulomb.social/coulomb/target-revenue:0.1.4
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
env:
|
env:
|
||||||
- name: TRF_RUN_MIGRATIONS
|
- name: TRF_RUN_MIGRATIONS
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ from psycopg_pool import ConnectionPool
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
from .. import breach_record, control_plane, ledger, metrics, registry
|
from .. import breach_record, control_plane, ledger, metrics, registry
|
||||||
from . import keys, reference_docs
|
from . import guidance, keys, reference_docs
|
||||||
|
|
||||||
_STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
|
_STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
|
||||||
_TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "control_plane_templates")
|
_TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "control_plane_templates")
|
||||||
|
|
@ -201,8 +201,27 @@ def dashboard(
|
||||||
conn: Connection = Depends(get_connection),
|
conn: Connection = Depends(get_connection),
|
||||||
):
|
):
|
||||||
phases = registry.list_phase_manifests_for_licensor(conn, licensor.licensor_id)
|
phases = registry.list_phase_manifests_for_licensor(conn, licensor.licensor_id)
|
||||||
|
pending = 0
|
||||||
|
if registry.has_right(licensor.rights, "operator"):
|
||||||
|
pending = len(control_plane.list_proposed_entries(conn, status="pending"))
|
||||||
|
first_id = phases[0]["phase"]["id"] if phases else None
|
||||||
|
next_steps = guidance.dashboard_guidance(
|
||||||
|
rights=licensor.rights,
|
||||||
|
phase_count=len(phases),
|
||||||
|
pending_proposals=pending,
|
||||||
|
root_path=_root_path(request),
|
||||||
|
first_phase_id=first_id,
|
||||||
|
)
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request, "dashboard.html", _template_context(request, licensor, phases=phases)
|
request,
|
||||||
|
"dashboard.html",
|
||||||
|
_template_context(
|
||||||
|
request,
|
||||||
|
licensor,
|
||||||
|
phases=phases,
|
||||||
|
next_steps=next_steps,
|
||||||
|
pending_proposals=pending,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -291,6 +310,27 @@ def phase_detail(
|
||||||
phase_attestation = control_plane.get_or_publish_attestation(
|
phase_attestation = control_plane.get_or_publish_attestation(
|
||||||
conn, phase_id, signing_key
|
conn, phase_id, signing_key
|
||||||
)
|
)
|
||||||
|
extension_count = len(registry.list_extensions(conn))
|
||||||
|
pending_for_phase = 0
|
||||||
|
if registry.has_right(licensor.rights, "operator"):
|
||||||
|
pending_for_phase = len(
|
||||||
|
control_plane.list_proposed_entries(conn, phase_id=phase_id, status="pending")
|
||||||
|
)
|
||||||
|
next_steps = guidance.phase_guidance(
|
||||||
|
rights=licensor.rights,
|
||||||
|
root_path=_root_path(request),
|
||||||
|
phase_id=phase_id,
|
||||||
|
metrics=computed_metrics,
|
||||||
|
ledger_len=len(entries),
|
||||||
|
extension_count=extension_count,
|
||||||
|
has_attestation=phase_attestation is not None,
|
||||||
|
pending_proposals_for_phase=pending_for_phase,
|
||||||
|
)
|
||||||
|
checklist = guidance.phase_lifecycle_checklist(
|
||||||
|
ledger_len=len(entries),
|
||||||
|
metrics=computed_metrics,
|
||||||
|
has_attestation=phase_attestation is not None,
|
||||||
|
)
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"phase_detail.html",
|
"phase_detail.html",
|
||||||
|
|
@ -302,6 +342,8 @@ def phase_detail(
|
||||||
metrics=computed_metrics,
|
metrics=computed_metrics,
|
||||||
breaches=breaches,
|
breaches=breaches,
|
||||||
attestation=phase_attestation,
|
attestation=phase_attestation,
|
||||||
|
next_steps=next_steps,
|
||||||
|
checklist=checklist,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script type="module" src="{{ root_path }}/static/whynot-design/index.js"></script>
|
<script type="module" src="{{ root_path }}/static/whynot-design/index.js"></script>
|
||||||
|
<link rel="stylesheet" href="{{ root_path }}/static/control-plane.css">
|
||||||
<style>
|
<style>
|
||||||
body { max-width: 960px; margin: 0 auto; padding: 1.5rem; font-family: system-ui, sans-serif; }
|
body { max-width: 960px; margin: 0 auto; padding: 1.5rem; font-family: system-ui, sans-serif; }
|
||||||
form.wn-form { display: flex; flex-direction: column; gap: 0.75rem; max-width: 480px; }
|
form.wn-form { display: flex; flex-direction: column; gap: 0.75rem; max-width: 480px; }
|
||||||
|
|
@ -20,7 +21,7 @@
|
||||||
form.wn-form input, form.wn-form select, form.wn-form textarea {
|
form.wn-form input, form.wn-form select, form.wn-form textarea {
|
||||||
padding: 0.5rem 0.6rem; font-size: 1rem; border: 1px solid #ccc; border-radius: 4px;
|
padding: 0.5rem 0.6rem; font-size: 1rem; border: 1px solid #ccc; border-radius: 4px;
|
||||||
}
|
}
|
||||||
form.wn-form button[type=submit] {
|
form.wn-form button[type=submit], form.wn-form button[type=submit].secondary {
|
||||||
padding: 0.55rem 1rem; font-size: 1rem; cursor: pointer;
|
padding: 0.55rem 1rem; font-size: 1rem; cursor: pointer;
|
||||||
background: #1a1a1a; color: #fff; border: none; border-radius: 4px; width: fit-content;
|
background: #1a1a1a; color: #fff; border: none; border-radius: 4px; width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
@ -93,6 +94,19 @@
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Guided-step anchors: smooth-scroll + brief highlight.
|
||||||
|
document.querySelectorAll("[data-cp-scroll]").forEach(function (link) {
|
||||||
|
link.addEventListener("click", function (ev) {
|
||||||
|
var id = link.getAttribute("data-cp-scroll");
|
||||||
|
var el = id && document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
el.classList.add("cp-section-highlight");
|
||||||
|
setTimeout(function () { el.classList.remove("cp-section-highlight"); }, 1800);
|
||||||
|
});
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,54 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% from "macros.html" import help_bubble, render_next_steps %}
|
||||||
{% block title %}Dashboard — Target Revenue Control Plane{% endblock %}
|
{% block title %}Dashboard — Target Revenue Control Plane{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<wn-page-header>
|
<wn-page-header>
|
||||||
<span slot="title">Phases for {{ session_licensor_id }}</span>
|
<span slot="title">
|
||||||
|
Phases for {{ session_licensor_id }}
|
||||||
|
{{ help_bubble(
|
||||||
|
"A Phase is one monetization undertaking: an Initial Target, Milestone Release, "
|
||||||
|
"Future License, and degeneration policy. Ledger activity (Development Credit and "
|
||||||
|
"Remission Credit) always belongs to a Phase."
|
||||||
|
) }}
|
||||||
|
</span>
|
||||||
</wn-page-header>
|
</wn-page-header>
|
||||||
|
|
||||||
|
{{ render_next_steps(next_steps, title='Recommended next step') }}
|
||||||
|
|
||||||
{% if session_rights in ("operator", "admin") %}
|
{% if session_rights in ("operator", "admin") %}
|
||||||
<p><a href="{{ root_path }}/phases/new">+ Register a new Phase</a></p>
|
<p>
|
||||||
|
<a href="{{ root_path }}/phases/new">+ Register a new Phase</a>
|
||||||
|
{{ help_bubble(
|
||||||
|
"Operators declare Phases. Registration is append-only: the Manifest cannot be "
|
||||||
|
"edited in place after publish. Choose Initial Target carefully."
|
||||||
|
) }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if pending_proposals and pending_proposals > 0 and session_rights in ("operator", "admin") %}
|
||||||
|
<p>
|
||||||
|
<a href="{{ root_path }}/proposals"><strong>{{ pending_proposals }}</strong> proposal(s) awaiting review</a>
|
||||||
|
{{ help_bubble("Contributors submit Development Credit; Operators approve or reject.") }}
|
||||||
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if phases %}
|
{% if phases %}
|
||||||
<table class="wn-plain">
|
<table class="wn-plain">
|
||||||
<thead><tr><th>Phase</th><th>Milestone Release</th><th>Initial Target</th><th></th></tr></thead>
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Phase {{ help_bubble("Globally unique id, usually trsl:phase:…") }}</th>
|
||||||
|
<th>Milestone Release</th>
|
||||||
|
<th>Initial Target {{ help_bubble("Immutable commercial ceiling for this Phase. Outstanding Target starts here.") }}</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for phase in phases %}
|
{% for phase in phases %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ phase.phase.id }}</td>
|
<td>{{ phase.phase.id }}</td>
|
||||||
<td>{{ phase.phase.milestone_release.name }}</td>
|
<td>{{ phase.phase.milestone_release.name }}</td>
|
||||||
<td>{{ phase.phase.initial_target.amount }} {{ phase.phase.initial_target.currency }}</td>
|
<td>{{ phase.phase.initial_target.amount }} {{ phase.phase.initial_target.currency }}</td>
|
||||||
<td><a href="{{ root_path }}/phases/{{ phase.phase.id }}">View</a></td>
|
<td><a href="{{ root_path }}/phases/{{ phase.phase.id }}">Open →</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,34 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% from "macros.html" import help_bubble, render_next_steps %}
|
||||||
{% block title %}Extensions — Target Revenue Control Plane{% endblock %}
|
{% block title %}Extensions — Target Revenue Control Plane{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<wn-page-header>
|
<wn-page-header>
|
||||||
<span slot="title">Monetization Extension Registry</span>
|
<span slot="title">
|
||||||
|
Monetization Extension Registry
|
||||||
|
{{ help_bubble(
|
||||||
|
"Extensions describe how commercial value becomes Development Credit "
|
||||||
|
"(pricing, allocation, recognition, evidence). Ledger credit rows reference them."
|
||||||
|
) }}
|
||||||
|
</span>
|
||||||
</wn-page-header>
|
</wn-page-header>
|
||||||
|
|
||||||
<p style="color:#888;font-size:0.9rem;">
|
{% if session_rights in ("operator", "admin") %}
|
||||||
|
{% set ext_steps = [
|
||||||
|
{
|
||||||
|
"key": "register",
|
||||||
|
"title": "Register an extension if you need a new profile",
|
||||||
|
"body": "Start with trsl:extension:development-license@1.0 for license-style sales. Operator+ can register; only Admin can promote to canonical.",
|
||||||
|
"priority": "recommended",
|
||||||
|
"section": "ext-form",
|
||||||
|
"cta": "Jump to form",
|
||||||
|
},
|
||||||
|
] %}
|
||||||
|
{{ render_next_steps(ext_steps, title='Extension workflow') }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<p class="cp-field-hint">
|
||||||
Rights: <strong>Operator+</strong> may register; <strong>Admin</strong> may
|
Rights: <strong>Operator+</strong> may register; <strong>Admin</strong> may
|
||||||
promote to <code>canonical</code> (governance action, never automated).
|
promote to <code>canonical</code> (governance action, never automated).
|
||||||
See <code>specs/TargetRevenueControlPlaneConcept.md</code> §2.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{% if extensions %}
|
{% if extensions %}
|
||||||
|
|
@ -52,6 +72,7 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if session_rights in ("operator", "admin") %}
|
{% if session_rights in ("operator", "admin") %}
|
||||||
|
<div id="ext-form">
|
||||||
<h3>Register a new extension</h3>
|
<h3>Register a new extension</h3>
|
||||||
<form class="wn-form" method="post" action="{{ root_path }}/extensions">
|
<form class="wn-form" method="post" action="{{ root_path }}/extensions">
|
||||||
<label>Extension id (trsl:extension:...)
|
<label>Extension id (trsl:extension:...)
|
||||||
|
|
@ -83,5 +104,6 @@
|
||||||
</label>
|
</label>
|
||||||
<button type="submit">Register extension</button>
|
<button type="submit">Register extension</button>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,51 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% from "macros.html" import help_bubble, render_next_steps %}
|
||||||
{% block title %}Sign in — Target Revenue Control Plane{% endblock %}
|
{% block title %}Sign in — Target Revenue Control Plane{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<wn-page-header>
|
<wn-page-header>
|
||||||
<span slot="title">Sign in</span>
|
<span slot="title">
|
||||||
|
Sign in
|
||||||
|
{{ help_bubble(
|
||||||
|
"This UI is multi-user: you authenticate with a personal credential token "
|
||||||
|
"issued under the binky Licensor tenant, not a shared password."
|
||||||
|
) }}
|
||||||
|
</span>
|
||||||
</wn-page-header>
|
</wn-page-header>
|
||||||
<p>Paste the credential token you were issued by an Admin.</p>
|
|
||||||
{# Native controls: reliable under /ui mount even if Lit components fail to hydrate. #}
|
{% set login_steps = [
|
||||||
|
{
|
||||||
|
"key": "get_token",
|
||||||
|
"title": "1. Obtain your credential token",
|
||||||
|
"body": "Admins issue tokens in Credentials. The founding pilot token is stored in OpenBao at platform/operators/founding-admin/revenue (field token).",
|
||||||
|
"priority": "recommended",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "paste",
|
||||||
|
"title": "2. Paste the token below",
|
||||||
|
"body": "Use the raw 64-character hex value only — no quotes. Whitespace is stripped automatically.",
|
||||||
|
"priority": "recommended",
|
||||||
|
"section": "login-form",
|
||||||
|
"cta": "Jump to form",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "continue",
|
||||||
|
"title": "3. Follow the dashboard guide",
|
||||||
|
"body": "After sign-in, the dashboard suggests the next recommended action for your rights tier.",
|
||||||
|
"priority": "optional",
|
||||||
|
},
|
||||||
|
] %}
|
||||||
|
{{ render_next_steps(login_steps, title='How to sign in') }}
|
||||||
|
|
||||||
|
<div id="login-form">
|
||||||
<form class="wn-form" method="post" action="{{ root_path }}/login">
|
<form class="wn-form" method="post" action="{{ root_path }}/login">
|
||||||
<label>
|
<label>
|
||||||
Credential token
|
<span class="cp-label-row">
|
||||||
|
Credential token
|
||||||
|
{{ help_bubble("OpenBao: platform/operators/founding-admin/revenue → field `token`. Never commit tokens to git.") }}
|
||||||
|
</span>
|
||||||
<input type="password" name="token" required autocomplete="current-password">
|
<input type="password" name="token" required autocomplete="current-password">
|
||||||
</label>
|
</label>
|
||||||
<button type="submit">Sign in</button>
|
<button type="submit">Sign in</button>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
{# Shared Control Plane UI macros: bubble help + next-step guide. #}
|
||||||
|
|
||||||
|
{% macro help_bubble(text, label='Help') -%}
|
||||||
|
<span class="cp-help">
|
||||||
|
<button type="button" class="cp-help__btn" aria-label="{{ label }}">?</button>
|
||||||
|
<span class="cp-help__bubble" role="tooltip">{{ text }}</span>
|
||||||
|
</span>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% macro render_next_steps(steps, title='What to do next') -%}
|
||||||
|
{% if steps %}
|
||||||
|
<section class="cp-guide" aria-label="{{ title }}">
|
||||||
|
<div class="cp-guide__header">
|
||||||
|
<div>
|
||||||
|
<div class="cp-guide__eyebrow">Guided steps</div>
|
||||||
|
<h2 class="cp-guide__title">{{ title }}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ol class="cp-guide__list">
|
||||||
|
{% for s in steps %}
|
||||||
|
<li class="cp-guide__item cp-guide__item--{{ s.priority }}">
|
||||||
|
<span class="cp-guide__badge">
|
||||||
|
{% if s.priority == 'recommended' %}Next
|
||||||
|
{% elif s.priority == 'optional' %}Optional
|
||||||
|
{% else %}Status{% endif %}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p class="cp-guide__item-title">{{ s.title }}</p>
|
||||||
|
<p class="cp-guide__item-body">{{ s.body }}</p>
|
||||||
|
</div>
|
||||||
|
{% if s.href and s.cta %}
|
||||||
|
<a class="cp-guide__cta" href="{{ s.href }}">{{ s.cta }}</a>
|
||||||
|
{% elif s.section and s.cta %}
|
||||||
|
<a class="cp-guide__cta" href="#{{ s.section }}" data-cp-scroll="{{ s.section }}">{{ s.cta }}</a>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% macro lifecycle_checklist(items) -%}
|
||||||
|
{% if items %}
|
||||||
|
<ul class="cp-checklist" aria-label="Phase lifecycle progress">
|
||||||
|
{% for item in items %}
|
||||||
|
<li class="cp-checklist__item{% if item.done %} cp-checklist__item--done{% endif %}">
|
||||||
|
<span class="cp-checklist__dot" aria-hidden="true"></span>
|
||||||
|
{{ item.label }}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
{%- endmacro %}
|
||||||
|
|
@ -1,13 +1,29 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% from "macros.html" import help_bubble, render_next_steps, lifecycle_checklist %}
|
||||||
{% block title %}{{ manifest.phase.id }} — Target Revenue Control Plane{% endblock %}
|
{% block title %}{{ manifest.phase.id }} — Target Revenue Control Plane{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<wn-page-header>
|
<wn-page-header>
|
||||||
<span slot="title">{{ manifest.phase.milestone_release.name }}</span>
|
<span slot="title">
|
||||||
|
{{ manifest.phase.milestone_release.name }}
|
||||||
|
{{ help_bubble(
|
||||||
|
"This page is the operator cockpit for one Phase: status, ledger, remission, "
|
||||||
|
"conversion attestation, and breach records."
|
||||||
|
) }}
|
||||||
|
</span>
|
||||||
</wn-page-header>
|
</wn-page-header>
|
||||||
<p><wn-tag>{{ manifest.phase.id }}</wn-tag></p>
|
<p><wn-tag>{{ manifest.phase.id }}</wn-tag></p>
|
||||||
|
|
||||||
|
{{ lifecycle_checklist(checklist) }}
|
||||||
|
{{ render_next_steps(next_steps, title='What to do next on this Phase') }}
|
||||||
|
|
||||||
<details style="margin-bottom:1rem;color:#888;font-size:0.9rem;">
|
<details style="margin-bottom:1rem;color:#888;font-size:0.9rem;">
|
||||||
<summary style="cursor:pointer;">Ledger reference (internal — not this Phase's ledger entries, see below)</summary>
|
<summary style="cursor:pointer;">
|
||||||
|
Ledger reference (internal)
|
||||||
|
{{ help_bubble(
|
||||||
|
"The raw ledger URI stored on the Manifest. Live entries are listed below; "
|
||||||
|
"this drill-down is for federation-ready reference, not day-to-day use."
|
||||||
|
) }}
|
||||||
|
</summary>
|
||||||
<p>
|
<p>
|
||||||
Raw reference: <code>{{ manifest.phase.ledger }}</code>
|
Raw reference: <code>{{ manifest.phase.ledger }}</code>
|
||||||
{% if manifest.phase.ledger.startswith('/phases/') %}
|
{% if manifest.phase.ledger.startswith('/phases/') %}
|
||||||
|
|
@ -16,12 +32,30 @@
|
||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<h3>Status</h3>
|
<h3>
|
||||||
|
Status
|
||||||
|
{{ help_bubble(
|
||||||
|
"Outstanding Target = max(0, Initial Target − Development Credit − Remission Credit). "
|
||||||
|
"When it reaches zero, the Phase converts to the Future License automatically."
|
||||||
|
) }}
|
||||||
|
</h3>
|
||||||
<table class="wn-plain">
|
<table class="wn-plain">
|
||||||
<tr><td>Initial Target</td><td>{{ manifest.phase.initial_target.amount }} {{ manifest.phase.initial_target.currency }}</td></tr>
|
<tr>
|
||||||
<tr><td>Cumulative Development Credit</td><td>{{ metrics.facts.cumulative_development_credit }}</td></tr>
|
<td>Initial Target {{ help_bubble("Immutable commercial ceiling declared at Phase registration.") }}</td>
|
||||||
<tr><td>Cumulative Remission Credit</td><td>{{ metrics.facts.cumulative_remission_credit }}</td></tr>
|
<td>{{ manifest.phase.initial_target.amount }} {{ manifest.phase.initial_target.currency }}</td>
|
||||||
<tr><td>Outstanding Target</td><td>{{ metrics.facts.outstanding_target }}</td></tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Cumulative Development Credit {{ help_bubble("Settled commercial payments allocated to this Phase (not revenue accounting).") }}</td>
|
||||||
|
<td>{{ metrics.facts.cumulative_development_credit }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Cumulative Remission Credit {{ help_bubble("Time-based reduction under the degeneration policy — never represented as captured revenue.") }}</td>
|
||||||
|
<td>{{ metrics.facts.cumulative_remission_credit }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Outstanding Target</td>
|
||||||
|
<td><strong>{{ metrics.facts.outstanding_target }}</strong></td>
|
||||||
|
</tr>
|
||||||
<tr><td>Target satisfaction</td><td>{{ metrics.calculations.target_satisfaction_percentage }}%</td></tr>
|
<tr><td>Target satisfaction</td><td>{{ metrics.calculations.target_satisfaction_percentage }}%</td></tr>
|
||||||
<tr><td>Converted</td><td>{{ metrics.facts.is_converted }}</td></tr>
|
<tr><td>Converted</td><td>{{ metrics.facts.is_converted }}</td></tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -31,11 +65,24 @@
|
||||||
· <a href="{{ root_path }}/reference/policies/{{ policy_slug(manifest.phase.degeneration_policy) }}">view spec</a>
|
· <a href="{{ root_path }}/reference/policies/{{ policy_slug(manifest.phase.degeneration_policy) }}">view spec</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr><td>Longstop</td><td>{{ metrics.facts.longstop_at or "—" }}</td></tr>
|
<tr>
|
||||||
<tr><td>Activated (t0)</td><td>{{ metrics.facts.activated_at or "—" }}</td></tr>
|
<td>Longstop {{ help_bubble("Full-remission instant: by this time Remission Credit alone can exhaust the Initial Target.") }}</td>
|
||||||
|
<td>{{ metrics.facts.longstop_at or "—" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Activated (t0) {{ help_bubble("Phase activation time for remission math — Trust Service registration timestamp.") }}</td>
|
||||||
|
<td>{{ metrics.facts.activated_at or "—" }}</td>
|
||||||
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<h3>Remission forecast <wn-tag>forecast</wn-tag></h3>
|
<div id="section-remission">
|
||||||
|
<h3>
|
||||||
|
Remission forecast <wn-tag>forecast</wn-tag>
|
||||||
|
{{ help_bubble(
|
||||||
|
"Forecasts are projections, not ledger facts. Applying remission writes a real "
|
||||||
|
"remission-credit entry only when the cumulative formula is ahead of what is already recorded."
|
||||||
|
) }}
|
||||||
|
</h3>
|
||||||
<table class="wn-plain">
|
<table class="wn-plain">
|
||||||
<tr>
|
<tr>
|
||||||
<td>If applied now</td>
|
<td>If applied now</td>
|
||||||
|
|
@ -65,14 +112,21 @@
|
||||||
{% if session_rights in ("operator", "admin") %}
|
{% if session_rights in ("operator", "admin") %}
|
||||||
<form class="wn-form" method="post" action="{{ root_path }}/phases/{{ manifest.phase.id }}/remission" style="margin-bottom:1.5rem;">
|
<form class="wn-form" method="post" action="{{ root_path }}/phases/{{ manifest.phase.id }}/remission" style="margin-bottom:1.5rem;">
|
||||||
<button type="submit" class="secondary">Apply policy remission now</button>
|
<button type="submit" class="secondary">Apply policy remission now</button>
|
||||||
<p style="color:#888;font-size:0.85rem;margin-top:0.5rem;">
|
<p class="cp-field-hint">
|
||||||
Writes a <code>remission-credit</code> delta under <code>system:policy-engine</code>
|
Writes a <code>remission-credit</code> delta under <code>system:policy-engine</code>
|
||||||
(idempotent — no double-remit if already current).
|
(idempotent — no double-remit if already current).
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<h3>Ledger ({{ ledger | length }} entries)</h3>
|
<h3>
|
||||||
|
Ledger ({{ ledger | length }} entries)
|
||||||
|
{{ help_bubble(
|
||||||
|
"Append-only. Corrections are new compensating entries (credit-reversal / "
|
||||||
|
"remission-correction), never edits of prior rows."
|
||||||
|
) }}
|
||||||
|
</h3>
|
||||||
{% if ledger %}
|
{% if ledger %}
|
||||||
<table class="wn-plain">
|
<table class="wn-plain">
|
||||||
<thead><tr><th>id</th><th>type</th><th>amount</th><th>recognized_at</th></tr></thead>
|
<thead><tr><th>id</th><th>type</th><th>amount</th><th>recognized_at</th></tr></thead>
|
||||||
|
|
@ -87,38 +141,56 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if session_rights in ("contributor", "operator", "admin") %}
|
{% if session_rights in ("contributor", "operator", "admin") %}
|
||||||
|
<div id="section-ledger-form">
|
||||||
<h3>
|
<h3>
|
||||||
{% if session_rights == "contributor" %}Propose a Development Credit entry{% else %}Add a Development Credit entry{% endif %}
|
{% if session_rights == "contributor" %}Propose a Development Credit entry{% else %}Add a Development Credit entry{% endif %}
|
||||||
|
{{ help_bubble(
|
||||||
|
"Use settlement time (not invoice time). Extension id must match a registered "
|
||||||
|
"monetization extension. Evidence can use the confidential: scheme for private refs."
|
||||||
|
) }}
|
||||||
</h3>
|
</h3>
|
||||||
<form class="wn-form" method="post" action="{{ root_path }}/phases/{{ manifest.phase.id }}/ledger">
|
<form class="wn-form" method="post" action="{{ root_path }}/phases/{{ manifest.phase.id }}/ledger">
|
||||||
<label>Entry id (trsl:entry:...)
|
<label>
|
||||||
<input name="entry_id" required>
|
<span class="cp-label-row">Entry id (trsl:entry:…) {{ help_bubble("Globally unique; letters and digits only after trsl:entry: (no hyphens).") }}</span>
|
||||||
|
<input name="entry_id" required placeholder="trsl:entry:dc001">
|
||||||
</label>
|
</label>
|
||||||
<label>Amount
|
<label>
|
||||||
|
<span class="cp-label-row">Amount {{ help_bubble("Positive amount in the Phase native currency.") }}</span>
|
||||||
<input type="number" step="0.01" name="amount" required>
|
<input type="number" step="0.01" name="amount" required>
|
||||||
</label>
|
</label>
|
||||||
<label>Currency
|
<label>Currency
|
||||||
<input name="currency" value="{{ manifest.phase.initial_target.currency }}" required>
|
<input name="currency" value="{{ manifest.phase.initial_target.currency }}" required>
|
||||||
</label>
|
</label>
|
||||||
<label>Recognized at (ISO 8601)
|
<label>
|
||||||
<input name="recognized_at" required>
|
<span class="cp-label-row">Recognized at (ISO 8601) {{ help_bubble("Payment-settled time, e.g. 2026-08-06T12:00:00Z.") }}</span>
|
||||||
|
<input name="recognized_at" required placeholder="2026-08-06T12:00:00Z">
|
||||||
</label>
|
</label>
|
||||||
<label>Evidence reference
|
<label>
|
||||||
<input name="evidence_reference" required>
|
<span class="cp-label-row">Evidence reference {{ help_bubble("URI or confidential:… id pointing at settlement evidence.") }}</span>
|
||||||
|
<input name="evidence_reference" required placeholder="confidential:evidence:…">
|
||||||
</label>
|
</label>
|
||||||
<label>Extension id (trsl:extension:...)
|
<label>
|
||||||
<input name="extension_id" required>
|
<span class="cp-label-row">Extension id {{ help_bubble("Usually trsl:extension:development-license for license sales.") }}</span>
|
||||||
|
<input name="extension_id" required value="trsl:extension:development-license">
|
||||||
</label>
|
</label>
|
||||||
<label>Extension version
|
<label>Extension version
|
||||||
<input name="extension_version" required>
|
<input name="extension_version" required value="1.0">
|
||||||
</label>
|
</label>
|
||||||
<button type="submit">
|
<button type="submit">
|
||||||
{% if session_rights == "contributor" %}Submit for review{% else %}Append{% endif %}
|
{% if session_rights == "contributor" %}Submit for review{% else %}Append{% endif %}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<h3>Conversion Attestation</h3>
|
<div id="section-attestation">
|
||||||
|
<h3>
|
||||||
|
Conversion Attestation
|
||||||
|
{{ help_bubble(
|
||||||
|
"Signed public evidence that conversion occurred. Conversion itself is true "
|
||||||
|
"the moment the fold hits zero — the attestation only observes and publishes it."
|
||||||
|
) }}
|
||||||
|
</h3>
|
||||||
{% if attestation %}
|
{% if attestation %}
|
||||||
<table class="wn-plain">
|
<table class="wn-plain">
|
||||||
<tr><td>Phase</td><td>{{ attestation.phase }}</td></tr>
|
<tr><td>Phase</td><td>{{ attestation.phase }}</td></tr>
|
||||||
|
|
@ -136,8 +208,15 @@
|
||||||
{% else %}
|
{% else %}
|
||||||
<wn-empty-state>Not converted — no attestation yet.</wn-empty-state>
|
<wn-empty-state>Not converted — no attestation yet.</wn-empty-state>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<h3>Breach / Compliance Records ({{ breaches | length }})</h3>
|
<h3>
|
||||||
|
Breach / Compliance Records ({{ breaches | length }})
|
||||||
|
{{ help_bubble(
|
||||||
|
"Licensor determinations only — anonymized by default. Named disclosure requires "
|
||||||
|
"an explicit CUA authorization assertion (License §7.4)."
|
||||||
|
) }}
|
||||||
|
</h3>
|
||||||
{% if breaches %}
|
{% if breaches %}
|
||||||
<table class="wn-plain">
|
<table class="wn-plain">
|
||||||
<thead>
|
<thead>
|
||||||
|
|
@ -175,10 +254,9 @@
|
||||||
|
|
||||||
{% if session_rights in ("operator", "admin") %}
|
{% if session_rights in ("operator", "admin") %}
|
||||||
<h3>Publish a breach/compliance event</h3>
|
<h3>Publish a breach/compliance event</h3>
|
||||||
<p style="color:#888;font-size:0.85rem;">
|
<p class="cp-field-hint">
|
||||||
Anonymized by default (Phase + category only). Named disclosure requires
|
Anonymized by default (Phase + category only). Named disclosure requires
|
||||||
an affirmative CUA authorization check (License V1C1 §7.4) — this form
|
an affirmative CUA authorization check.
|
||||||
records that assertion; it does not verify the CUA text.
|
|
||||||
</p>
|
</p>
|
||||||
<form class="wn-form" method="post" action="{{ root_path }}/phases/{{ manifest.phase.id }}/breach">
|
<form class="wn-form" method="post" action="{{ root_path }}/phases/{{ manifest.phase.id }}/breach">
|
||||||
<label>Record id (unique)
|
<label>Record id (unique)
|
||||||
|
|
|
||||||
|
|
@ -1,59 +1,121 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% from "macros.html" import help_bubble, render_next_steps %}
|
||||||
{% block title %}Register a Phase — Target Revenue Control Plane{% endblock %}
|
{% block title %}Register a Phase — Target Revenue Control Plane{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<wn-page-header>
|
<wn-page-header>
|
||||||
<span slot="title">Register a new Phase (Operator+)</span>
|
<span slot="title">
|
||||||
|
Register a new Phase (Operator+)
|
||||||
|
{{ help_bubble(
|
||||||
|
"Registration is permanent for this Phase id. Validate numbers and repo "
|
||||||
|
"provenance before submit — there is no in-place Manifest edit."
|
||||||
|
) }}
|
||||||
|
</span>
|
||||||
</wn-page-header>
|
</wn-page-header>
|
||||||
<p style="color:#888;">
|
|
||||||
|
{% set form_steps = [
|
||||||
|
{
|
||||||
|
"key": "identity",
|
||||||
|
"title": "1. Identify the Phase and repo",
|
||||||
|
"body": "Choose a unique phase id, name the Milestone Release, and pin the Forgejo repo (hub, id, name, revision).",
|
||||||
|
"priority": "recommended",
|
||||||
|
"section": "step-identity",
|
||||||
|
"cta": "Jump to identity fields",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "target",
|
||||||
|
"title": "2. Set the commercial target",
|
||||||
|
"body": "Initial Target amount + currency is immutable after registration. Use the effort calculator offline if needed.",
|
||||||
|
"priority": "recommended",
|
||||||
|
"section": "step-target",
|
||||||
|
"cta": "Jump to target fields",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "policy",
|
||||||
|
"title": "3. Future License, policy, longstop",
|
||||||
|
"body": "Declare MIT or Apache-2.0, the linear-longstop policy, and a hard full-remission date.",
|
||||||
|
"priority": "recommended",
|
||||||
|
"section": "step-policy",
|
||||||
|
"cta": "Jump to policy fields",
|
||||||
|
},
|
||||||
|
] %}
|
||||||
|
{{ render_next_steps(form_steps, title='How to fill this form') }}
|
||||||
|
|
||||||
|
<p class="cp-field-hint">
|
||||||
This registers a real Phase Manifest against the hosted Trust Service.
|
This registers a real Phase Manifest against the hosted Trust Service.
|
||||||
Further Phases beyond the T05 pilot still need an explicit declaration act.
|
Further Phases beyond the T05 pilot still need an explicit declaration act.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form class="wn-form" method="post" action="{{ root_path }}/phases/new">
|
<form class="wn-form" method="post" action="{{ root_path }}/phases/new">
|
||||||
<label>Phase id (trsl:phase:...)
|
<div id="step-identity">
|
||||||
<input name="phase_id" required>
|
<h3>Identity & repository</h3>
|
||||||
|
<label>
|
||||||
|
<span class="cp-label-row">Phase id (trsl:phase:…) {{ help_bubble("Globally unique, immutable. Example: trsl:phase:my-product-v1") }}</span>
|
||||||
|
<input name="phase_id" required placeholder="trsl:phase:…">
|
||||||
</label>
|
</label>
|
||||||
<label>Milestone Release name
|
<label>
|
||||||
|
<span class="cp-label-row">Milestone Release name {{ help_bubble("Human-readable name of the governed release / deliverable.") }}</span>
|
||||||
<input name="milestone_release_name" required>
|
<input name="milestone_release_name" required>
|
||||||
</label>
|
</label>
|
||||||
<label>Source revision
|
<label>
|
||||||
<input name="source_revision" required>
|
<span class="cp-label-row">Source revision {{ help_bubble("Git commit SHA of the Milestone Release being governed.") }}</span>
|
||||||
|
<input name="source_revision" required placeholder="abcdef…">
|
||||||
</label>
|
</label>
|
||||||
<label>Repo hub (stable Forgejo instance slug)
|
<label>
|
||||||
|
<span class="cp-label-row">Repo hub {{ help_bubble("Stable slug for the Forgejo instance, e.g. forgejo-coulomb.") }}</span>
|
||||||
<input name="repo_hub" value="forgejo-coulomb" required>
|
<input name="repo_hub" value="forgejo-coulomb" required>
|
||||||
</label>
|
</label>
|
||||||
<label>Repo hub URI
|
<label>
|
||||||
|
<span class="cp-label-row">Repo hub URI {{ help_bubble("Current base URL of that Forgejo, kept for repair if the domain changes.") }}</span>
|
||||||
<input name="repo_hub_uri" value="https://forgejo.coulomb.social" required>
|
<input name="repo_hub_uri" value="https://forgejo.coulomb.social" required>
|
||||||
</label>
|
</label>
|
||||||
<label>Repo id (Forgejo's own numeric id)
|
<label>
|
||||||
|
<span class="cp-label-row">Repo id {{ help_bubble("Forgejo numeric repo id (GET /api/v1/repos/{owner}/{repo} → id). Survives renames.") }}</span>
|
||||||
<input type="number" name="repo_id" required>
|
<input type="number" name="repo_id" required>
|
||||||
</label>
|
</label>
|
||||||
<label>Repo name (owner/repo)
|
<label>
|
||||||
<input name="repo_name" required>
|
<span class="cp-label-row">Repo name (owner/repo) {{ help_bubble("Human-readable slug at registration time.") }}</span>
|
||||||
|
<input name="repo_name" required placeholder="coulomb/my-repo">
|
||||||
</label>
|
</label>
|
||||||
<label>Base Phase id (optional — only for a successive Phase)
|
<label>
|
||||||
<input name="base_phase_id">
|
<span class="cp-label-row">Base Phase id (optional) {{ help_bubble("Only for a successive Phase on a repo that already converted an earlier Phase (FR-11).") }}</span>
|
||||||
|
<input name="base_phase_id" placeholder="trsl:phase:prior-…">
|
||||||
</label>
|
</label>
|
||||||
<label>Initial Target amount
|
</div>
|
||||||
|
|
||||||
|
<div id="step-target">
|
||||||
|
<h3>Commercial target</h3>
|
||||||
|
<label>
|
||||||
|
<span class="cp-label-row">Initial Target amount {{ help_bubble("Cannot be increased for commercial convenience after the Phase begins.") }}</span>
|
||||||
<input type="number" step="0.01" name="initial_target_amount" required>
|
<input type="number" step="0.01" name="initial_target_amount" required>
|
||||||
</label>
|
</label>
|
||||||
<label>Currency (ISO 4217)
|
<label>
|
||||||
|
<span class="cp-label-row">Currency (ISO 4217) {{ help_bubble("One native currency per Phase. All ledger entries must match.") }}</span>
|
||||||
<input name="currency" value="EUR" required>
|
<input name="currency" value="EUR" required>
|
||||||
</label>
|
</label>
|
||||||
<label>Future License
|
</div>
|
||||||
|
|
||||||
|
<div id="step-policy">
|
||||||
|
<h3>License & degeneration</h3>
|
||||||
|
<label>
|
||||||
|
<span class="cp-label-row">Future License {{ help_bubble("Permissive license that takes effect automatically at conversion.") }}</span>
|
||||||
<select name="future_license">
|
<select name="future_license">
|
||||||
<option value="MIT">MIT</option>
|
<option value="MIT">MIT</option>
|
||||||
<option value="Apache-2.0">Apache-2.0</option>
|
<option value="Apache-2.0">Apache-2.0</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>Degeneration policy
|
<label>
|
||||||
|
<span class="cp-label-row">Degeneration policy {{ help_bubble("v1 pilot norm: linear remission toward the longstop date.") }}</span>
|
||||||
<input name="degeneration_policy" value="trsl:policy:linear-longstop-v0@1.0" required>
|
<input name="degeneration_policy" value="trsl:policy:linear-longstop-v0@1.0" required>
|
||||||
</label>
|
</label>
|
||||||
<p style="margin-top:-0.5rem;color:#888;font-size:0.85rem;">
|
<p class="cp-field-hint">
|
||||||
<a href="{{ root_path }}/reference/policies/linear-longstop-v0">view the linear-longstop-v0 spec</a>
|
<a href="{{ root_path }}/reference/policies/linear-longstop-v0">view the linear-longstop-v0 spec</a>
|
||||||
</p>
|
</p>
|
||||||
<label>Longstop date (ISO 8601)
|
<label>
|
||||||
<input name="longstop_at" required>
|
<span class="cp-label-row">Longstop date (ISO 8601) {{ help_bubble("Required full-remission / maximum-protection instant, e.g. 2029-01-01T00:00:00Z.") }}</span>
|
||||||
|
<input name="longstop_at" required placeholder="2029-01-01T00:00:00Z">
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button type="submit">Register Phase</button>
|
<button type="submit">Register Phase</button>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
307
src/target_revenue/service/guidance.py
Normal file
307
src/target_revenue/service/guidance.py
Normal file
|
|
@ -0,0 +1,307 @@
|
||||||
|
"""Step-by-step next-action guidance for the Control Plane UI.
|
||||||
|
|
||||||
|
Pure helpers: given Phase/metrics/ledger/proposal context, return ordered
|
||||||
|
recommended steps so templates can show a clear "what next" card without
|
||||||
|
embedding lifecycle rules in Jinja.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def _step(
|
||||||
|
key: str,
|
||||||
|
title: str,
|
||||||
|
body: str,
|
||||||
|
*,
|
||||||
|
priority: str = "recommended",
|
||||||
|
href: str | None = None,
|
||||||
|
cta: str | None = None,
|
||||||
|
section: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"key": key,
|
||||||
|
"title": title,
|
||||||
|
"body": body,
|
||||||
|
"priority": priority, # recommended | optional | done_hint
|
||||||
|
"href": href,
|
||||||
|
"cta": cta,
|
||||||
|
"section": section, # HTML id to scroll/highlight
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def dashboard_guidance(
|
||||||
|
*,
|
||||||
|
rights: str,
|
||||||
|
phase_count: int,
|
||||||
|
pending_proposals: int,
|
||||||
|
root_path: str,
|
||||||
|
first_phase_id: str | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Guidance for the tenant dashboard."""
|
||||||
|
steps: list[dict[str, Any]] = []
|
||||||
|
if pending_proposals > 0 and rights in ("operator", "admin"):
|
||||||
|
steps.append(
|
||||||
|
_step(
|
||||||
|
"review_proposals",
|
||||||
|
f"Review {pending_proposals} pending proposal(s)",
|
||||||
|
"A Contributor submitted Development Credit for approval. "
|
||||||
|
"Approving appends it to the live ledger under your credential.",
|
||||||
|
priority="recommended",
|
||||||
|
href=f"{root_path}/proposals",
|
||||||
|
cta="Open proposals",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if phase_count == 0:
|
||||||
|
if rights in ("operator", "admin"):
|
||||||
|
steps.append(
|
||||||
|
_step(
|
||||||
|
"register_phase",
|
||||||
|
"Register your first Phase",
|
||||||
|
"A Phase is the bounded monetization unit: Initial Target, "
|
||||||
|
"Milestone Release, Future License, and degeneration policy. "
|
||||||
|
"Nothing is tracked until a Phase exists.",
|
||||||
|
priority="recommended",
|
||||||
|
href=f"{root_path}/phases/new",
|
||||||
|
cta="Register a Phase",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
steps.append(
|
||||||
|
_step(
|
||||||
|
"wait_for_phase",
|
||||||
|
"No Phases yet",
|
||||||
|
"An Operator must register a Phase before Development Credit "
|
||||||
|
"or Remission Credit can be recorded.",
|
||||||
|
priority="recommended",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if first_phase_id:
|
||||||
|
steps.append(
|
||||||
|
_step(
|
||||||
|
"open_phase",
|
||||||
|
"Continue on an existing Phase",
|
||||||
|
"Open a Phase to record Development Credit, apply Remission "
|
||||||
|
"Credit, or check conversion status.",
|
||||||
|
priority="recommended",
|
||||||
|
href=f"{root_path}/phases/{first_phase_id}",
|
||||||
|
cta="Open Phase",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if rights in ("operator", "admin"):
|
||||||
|
steps.append(
|
||||||
|
_step(
|
||||||
|
"register_another",
|
||||||
|
"Register another Phase (optional)",
|
||||||
|
"Each product line / Milestone Release needs its own Phase. "
|
||||||
|
"T05 only authorized the first pilot — further Phases are "
|
||||||
|
"deliberate declaration acts.",
|
||||||
|
priority="optional",
|
||||||
|
href=f"{root_path}/phases/new",
|
||||||
|
cta="New Phase",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return steps
|
||||||
|
|
||||||
|
|
||||||
|
def phase_guidance(
|
||||||
|
*,
|
||||||
|
rights: str,
|
||||||
|
root_path: str,
|
||||||
|
phase_id: str,
|
||||||
|
metrics: dict[str, Any],
|
||||||
|
ledger_len: int,
|
||||||
|
extension_count: int,
|
||||||
|
has_attestation: bool,
|
||||||
|
pending_proposals_for_phase: int = 0,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Ordered next steps for one Phase detail page."""
|
||||||
|
facts = metrics.get("facts") or {}
|
||||||
|
forecasts = metrics.get("forecasts") or {}
|
||||||
|
steps: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
if facts.get("is_converted"):
|
||||||
|
if has_attestation:
|
||||||
|
steps.append(
|
||||||
|
_step(
|
||||||
|
"converted",
|
||||||
|
"Phase is converted",
|
||||||
|
"Outstanding Target reached zero. The Future License applies "
|
||||||
|
"to the Milestone Release. Review the Conversion Attestation below.",
|
||||||
|
priority="done_hint",
|
||||||
|
section="section-attestation",
|
||||||
|
cta="Jump to attestation",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
steps.append(
|
||||||
|
_step(
|
||||||
|
"publish_attestation",
|
||||||
|
"Attestation will appear on refresh",
|
||||||
|
"Conversion is already true from the ledger fold. Reload this "
|
||||||
|
"page to publish/view the signed Conversion Attestation.",
|
||||||
|
priority="recommended",
|
||||||
|
section="section-attestation",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return steps
|
||||||
|
|
||||||
|
if pending_proposals_for_phase > 0 and rights in ("operator", "admin"):
|
||||||
|
steps.append(
|
||||||
|
_step(
|
||||||
|
"phase_proposals",
|
||||||
|
f"Approve or reject {pending_proposals_for_phase} proposal(s)",
|
||||||
|
"Pending Development Credit for this Phase is waiting in the "
|
||||||
|
"proposals queue.",
|
||||||
|
priority="recommended",
|
||||||
|
href=f"{root_path}/proposals",
|
||||||
|
cta="Review proposals",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if extension_count == 0 and rights in ("operator", "admin"):
|
||||||
|
steps.append(
|
||||||
|
_step(
|
||||||
|
"register_extension",
|
||||||
|
"Register a monetization extension",
|
||||||
|
"Development Credit entries reference an extension "
|
||||||
|
"(e.g. trsl:extension:development-license@1.0). Register one "
|
||||||
|
"before appending credits, or reuse an already-registered id.",
|
||||||
|
priority="recommended",
|
||||||
|
href=f"{root_path}/extensions",
|
||||||
|
cta="Open extensions",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
rem_now = forecasts.get("remission_if_applied_now")
|
||||||
|
if (
|
||||||
|
rem_now is not None
|
||||||
|
and float(rem_now) > 0
|
||||||
|
and rights in ("operator", "admin")
|
||||||
|
):
|
||||||
|
cur = facts.get("initial_target_currency") or ""
|
||||||
|
steps.append(
|
||||||
|
_step(
|
||||||
|
"apply_remission",
|
||||||
|
f"Apply policy remission (~{rem_now} {cur})",
|
||||||
|
"Linear-longstop has accrued Remission Credit that is not yet "
|
||||||
|
"on the ledger. Applying writes an idempotent remission-credit "
|
||||||
|
"entry under system:policy-engine.",
|
||||||
|
priority="recommended",
|
||||||
|
section="section-remission",
|
||||||
|
cta="Jump to remission",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if ledger_len == 0:
|
||||||
|
if rights in ("contributor", "operator", "admin"):
|
||||||
|
who = (
|
||||||
|
"Propose a Development Credit"
|
||||||
|
if rights == "contributor"
|
||||||
|
else "Record the first Development Credit"
|
||||||
|
)
|
||||||
|
steps.append(
|
||||||
|
_step(
|
||||||
|
"first_credit",
|
||||||
|
who,
|
||||||
|
"When a Commercial Entitlement payment settles, append a "
|
||||||
|
"development-credit entry (amount, evidence, extension). "
|
||||||
|
"That is how real monetization progress is tracked.",
|
||||||
|
priority="recommended" if not steps else "optional",
|
||||||
|
section="section-ledger-form",
|
||||||
|
cta="Jump to credit form",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
steps.append(
|
||||||
|
_step(
|
||||||
|
"viewer_empty",
|
||||||
|
"No ledger activity yet",
|
||||||
|
"Operators will record Development Credit and Remission Credit "
|
||||||
|
"as commercial activity and time progress occur.",
|
||||||
|
priority="recommended",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if rights in ("contributor", "operator", "admin"):
|
||||||
|
steps.append(
|
||||||
|
_step(
|
||||||
|
"more_credit",
|
||||||
|
"Add further Development Credit when payments settle",
|
||||||
|
"Each settled commercial payment that allocates to this Phase "
|
||||||
|
"should produce a new ledger entry (append-only; corrections "
|
||||||
|
"are compensating entries, never edits).",
|
||||||
|
priority="optional",
|
||||||
|
section="section-ledger-form",
|
||||||
|
cta="Jump to credit form",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
next_at = forecasts.get("next_scheduled_remission_at")
|
||||||
|
if next_at and rights in ("operator", "admin"):
|
||||||
|
steps.append(
|
||||||
|
_step(
|
||||||
|
"schedule_remission",
|
||||||
|
"Keep Remission on schedule",
|
||||||
|
f"Next scheduled recognition boundary: {next_at}. "
|
||||||
|
"Run Apply policy remission after that date (or set a monthly cron "
|
||||||
|
"against POST /remission/run).",
|
||||||
|
priority="optional",
|
||||||
|
section="section-remission",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not steps:
|
||||||
|
steps.append(
|
||||||
|
_step(
|
||||||
|
"monitor",
|
||||||
|
"Monitor Outstanding Target",
|
||||||
|
"No urgent action. Watch Development Credit and Remission Credit "
|
||||||
|
"until Outstanding Target reaches zero and conversion fires.",
|
||||||
|
priority="done_hint",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return steps
|
||||||
|
|
||||||
|
|
||||||
|
def phase_lifecycle_checklist(
|
||||||
|
*,
|
||||||
|
ledger_len: int,
|
||||||
|
metrics: dict[str, Any],
|
||||||
|
has_attestation: bool,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Visual checklist of lifecycle milestones for a Phase."""
|
||||||
|
facts = metrics.get("facts") or {}
|
||||||
|
converted = bool(facts.get("is_converted"))
|
||||||
|
has_dev = float(facts.get("cumulative_development_credit") or 0) > 0
|
||||||
|
has_rem = float(facts.get("cumulative_remission_credit") or 0) > 0
|
||||||
|
return [
|
||||||
|
{"id": "declared", "label": "Phase declared", "done": True},
|
||||||
|
{
|
||||||
|
"id": "activity",
|
||||||
|
"label": "Ledger activity started",
|
||||||
|
"done": ledger_len > 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dev_credit",
|
||||||
|
"label": "Development Credit recorded",
|
||||||
|
"done": has_dev,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "remission",
|
||||||
|
"label": "Remission Credit recorded",
|
||||||
|
"done": has_rem,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "converted",
|
||||||
|
"label": "Converted (Outstanding Target = 0)",
|
||||||
|
"done": converted,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "attestation",
|
||||||
|
"label": "Conversion Attestation published",
|
||||||
|
"done": has_attestation,
|
||||||
|
},
|
||||||
|
]
|
||||||
234
src/target_revenue/service/static/control-plane.css
Normal file
234
src/target_revenue/service/static/control-plane.css
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
/* Control Plane guidance + bubble help (native CSS; no Lit required). */
|
||||||
|
|
||||||
|
/* ---- Bubble help (? popovers) ---- */
|
||||||
|
.cp-help {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-left: 0.3rem;
|
||||||
|
}
|
||||||
|
.cp-help__btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.15rem;
|
||||||
|
height: 1.15rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid #888;
|
||||||
|
background: #f4f4f4;
|
||||||
|
color: #444;
|
||||||
|
font: 600 0.7rem/1 system-ui, sans-serif;
|
||||||
|
cursor: help;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.cp-help__btn:hover,
|
||||||
|
.cp-help__btn:focus {
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #1a1a1a;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.cp-help__bubble {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 40;
|
||||||
|
left: 50%;
|
||||||
|
bottom: calc(100% + 0.45rem);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: min(18rem, 70vw);
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #f5f5f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
font: 400 0.8rem/1.35 system-ui, sans-serif;
|
||||||
|
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25);
|
||||||
|
text-align: left;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
.cp-help__bubble::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border: 6px solid transparent;
|
||||||
|
border-top-color: #1a1a1a;
|
||||||
|
}
|
||||||
|
.cp-help:hover .cp-help__bubble,
|
||||||
|
.cp-help:focus-within .cp-help__bubble {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.cp-help__bubble a { color: #9ecbff; }
|
||||||
|
|
||||||
|
/* ---- Next-step guide card ---- */
|
||||||
|
.cp-guide {
|
||||||
|
border: 1px solid #d0d7de;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(180deg, #f8fafc 0%, #fff 48%);
|
||||||
|
padding: 1rem 1.15rem 1.1rem;
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
}
|
||||||
|
.cp-guide__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.cp-guide__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
.cp-guide__eyebrow {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #666;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.cp-guide__list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
.cp-guide__item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
gap: 0.65rem 0.85rem;
|
||||||
|
align-items: start;
|
||||||
|
padding: 0.7rem 0.8rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.cp-guide__item--recommended {
|
||||||
|
border-color: #93c5fd;
|
||||||
|
background: #eff6ff;
|
||||||
|
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.12);
|
||||||
|
}
|
||||||
|
.cp-guide__item--optional {
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
.cp-guide__item--done_hint {
|
||||||
|
border-color: #bbf7d0;
|
||||||
|
background: #f0fdf4;
|
||||||
|
}
|
||||||
|
.cp-guide__badge {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e5e7eb;
|
||||||
|
color: #374151;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.cp-guide__item--recommended .cp-guide__badge {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.cp-guide__item--done_hint .cp-guide__badge {
|
||||||
|
background: #16a34a;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.cp-guide__item-title {
|
||||||
|
margin: 0 0 0.2rem;
|
||||||
|
font-weight: 650;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.cp-guide__item-body {
|
||||||
|
margin: 0;
|
||||||
|
color: #444;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.cp-guide__cta {
|
||||||
|
display: inline-block;
|
||||||
|
align-self: center;
|
||||||
|
padding: 0.4rem 0.7rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #fff !important;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.cp-guide__item--recommended .cp-guide__cta {
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
.cp-guide__cta:hover { filter: brightness(1.08); }
|
||||||
|
|
||||||
|
/* ---- Lifecycle checklist ---- */
|
||||||
|
.cp-checklist {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin: 0 0 1.25rem;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.cp-checklist__item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.3rem 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #555;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
.cp-checklist__item--done {
|
||||||
|
border-color: #86efac;
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #166534;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.cp-checklist__dot {
|
||||||
|
width: 0.55rem;
|
||||||
|
height: 0.55rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ccc;
|
||||||
|
}
|
||||||
|
.cp-checklist__item--done .cp-checklist__dot {
|
||||||
|
background: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Highlight target sections ---- */
|
||||||
|
.cp-section-highlight {
|
||||||
|
animation: cp-pulse 1.6s ease-out 1;
|
||||||
|
outline: 2px solid #93c5fd;
|
||||||
|
outline-offset: 4px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
@keyframes cp-pulse {
|
||||||
|
0% { outline-color: #2563eb; background-color: #eff6ff; }
|
||||||
|
100% { outline-color: #93c5fd; background-color: transparent; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Field label row with help */
|
||||||
|
.cp-label-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
.cp-field-hint {
|
||||||
|
margin: 0.15rem 0 0;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.cp-guide__item {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.cp-guide__cta { justify-self: start; }
|
||||||
|
}
|
||||||
88
tests/test_guidance.py
Normal file
88
tests/test_guidance.py
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
"""Unit tests for Control Plane next-step guidance."""
|
||||||
|
|
||||||
|
from target_revenue.service import guidance
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_empty_operator_recommends_register():
|
||||||
|
steps = guidance.dashboard_guidance(
|
||||||
|
rights="operator",
|
||||||
|
phase_count=0,
|
||||||
|
pending_proposals=0,
|
||||||
|
root_path="/ui",
|
||||||
|
)
|
||||||
|
assert steps[0]["key"] == "register_phase"
|
||||||
|
assert steps[0]["priority"] == "recommended"
|
||||||
|
assert steps[0]["href"] == "/ui/phases/new"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_with_phase_opens_first():
|
||||||
|
steps = guidance.dashboard_guidance(
|
||||||
|
rights="viewer",
|
||||||
|
phase_count=1,
|
||||||
|
pending_proposals=0,
|
||||||
|
root_path="/ui",
|
||||||
|
first_phase_id="trsl:phase:demo",
|
||||||
|
)
|
||||||
|
assert steps[0]["key"] == "open_phase"
|
||||||
|
assert "trsl:phase:demo" in steps[0]["href"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_phase_empty_ledger_recommends_credit_or_remission():
|
||||||
|
metrics = {
|
||||||
|
"facts": {
|
||||||
|
"is_converted": False,
|
||||||
|
"cumulative_development_credit": 0,
|
||||||
|
"cumulative_remission_credit": 0,
|
||||||
|
"initial_target_currency": "EUR",
|
||||||
|
},
|
||||||
|
"forecasts": {"remission_if_applied_now": 12.5, "next_scheduled_remission_at": None},
|
||||||
|
}
|
||||||
|
steps = guidance.phase_guidance(
|
||||||
|
rights="operator",
|
||||||
|
root_path="/ui",
|
||||||
|
phase_id="trsl:phase:demo",
|
||||||
|
metrics=metrics,
|
||||||
|
ledger_len=0,
|
||||||
|
extension_count=1,
|
||||||
|
has_attestation=False,
|
||||||
|
)
|
||||||
|
keys = [s["key"] for s in steps]
|
||||||
|
assert "apply_remission" in keys
|
||||||
|
assert keys[0] == "apply_remission" # material remission first
|
||||||
|
|
||||||
|
|
||||||
|
def test_phase_converted_done_hint():
|
||||||
|
metrics = {
|
||||||
|
"facts": {"is_converted": True, "cumulative_development_credit": 1, "cumulative_remission_credit": 0},
|
||||||
|
"forecasts": {},
|
||||||
|
}
|
||||||
|
steps = guidance.phase_guidance(
|
||||||
|
rights="operator",
|
||||||
|
root_path="/ui",
|
||||||
|
phase_id="trsl:phase:demo",
|
||||||
|
metrics=metrics,
|
||||||
|
ledger_len=2,
|
||||||
|
extension_count=1,
|
||||||
|
has_attestation=True,
|
||||||
|
)
|
||||||
|
assert steps[0]["key"] == "converted"
|
||||||
|
assert steps[0]["priority"] == "done_hint"
|
||||||
|
|
||||||
|
|
||||||
|
def test_lifecycle_checklist_progress():
|
||||||
|
metrics = {
|
||||||
|
"facts": {
|
||||||
|
"is_converted": False,
|
||||||
|
"cumulative_development_credit": 10,
|
||||||
|
"cumulative_remission_credit": 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items = guidance.phase_lifecycle_checklist(
|
||||||
|
ledger_len=1, metrics=metrics, has_attestation=False
|
||||||
|
)
|
||||||
|
by_id = {i["id"]: i["done"] for i in items}
|
||||||
|
assert by_id["declared"] is True
|
||||||
|
assert by_id["activity"] is True
|
||||||
|
assert by_id["dev_credit"] is True
|
||||||
|
assert by_id["remission"] is False
|
||||||
|
assert by_id["converted"] is False
|
||||||
Loading…
Add table
Add a link
Reference in a new issue