@taxman Debits & credits ledger + NVM-persistent FREE/PAID DRAW Briefs — TDD
User-spec 2026-05-26 for /gameboard/my-sea/. The transient "Free draw locked" Brief that re-appeared on every page load is replaced by a server-driven Brief whose NVM dismissal persists per-cycle, AND every spend now lands a permanent line on a new @taxman-authored "Debits & credits" Post (so the info goes somewhere instead of vanishing on dismiss). Same NVM-persistence treatment for the new PAID DRAW Brief. Lyric: - RESERVED_USERNAMES adds "taxman"; get_or_create_taxman() parallels get_or_create_adman() (username=taxman, email=taxman@earthmanrpg.local, unusable password, searchable=False). - New nullable User.{free,paid}_draw_brief_dismissed_at DateTimeFields — anchor stamps for the NVM-persistence semantics. Cleared by my_sea_lock (free) / my_sea_paid_draw (paid) on each fresh spend so the new cycle re-opens the Brief surface. - Migration 0014_brief_dismissal_fields adds the fields + RunPython seeds @taxman (mirror of 0003_seed_adman). Billboard: - Post.KIND_TAX_LEDGER + TAX_LEDGER_POST_TITLE = "Debits & credits"; Brief.KIND_TAX_LEDGER for routing. - _delete_unsolicited_admin_post_lines extended via _SYSTEM_AUTHOR_POST_KINDS tuple — TAX_LEDGER joins NOTE_UNLOCK in the post_save guard that nukes any Line w.o. admin_solicited=True. - Brief.to_banner_dict adds dismiss_url slot (empty by default; populated by the gameboard view for TAX_LEDGER briefs) + uses line.display_text instead of line.text so the prefix is stripped on the banner too. - Line.display_text property — strips the leading "[iso-timestamp] " prefix that log_tax_debit bakes into TAX_LEDGER Lines (the prefix exists ONLY to satisfy unique_together = (post, text) on repeat-slug spends; the per-Brief + per-Line created_at slots already render the user-facing moment). Identity for non-tax Lines. - view_post / delete_post / abandon_post guards extended to treat TAX_LEDGER like NOTE_UNLOCK (POST forbidden, can't delete, can't bye). - Migration 0008_tax_ledger_kind registers the new choices on Post.kind + Brief.kind. Billboard tax module (new apps/billboard/tax.py): - TAX_DEBIT_TEMPLATES — canonical body text per slug, with FREE DRAW / PAID DRAW / GATE VIEW button-labels wrapped in .btn-pri-name spans: - free_draw_locked → "Look!—my_sea.html [FREE DRAW] is locked. Next free draw available 24h from the production of this log." - paid_draw_locked → "Look!—my_sea.html [PAID DRAW] is locked. Another may be unlocked by depositing a Token in [GATE VIEW]." - log_tax_debit(user, slug) — get-or-creates the user's TAX_LEDGER Post, appends a timestamp-prefixed Line authored by @taxman w. admin_solicited=True, spawns a Brief. Returns (post, line, brief). Gameboard: - my_sea_lock first-card-of-cycle branch calls log_tax_debit(user, "free_draw_locked") + clears free_draw_brief_dismissed_at. Response now includes free_draw_brief_payload (Brief.to_banner_dict w. dismiss_url populated) so the picker IIFE can surface the new Brief in-place w.o. a page reload — same affordance the prior _showFreeDrawLockedBrief provided, w. server-authored copy + NVM-persistence. - my_sea_paid_draw after paid_through_at stamp calls log_tax_debit(user, "paid_draw_locked") + clears paid_draw_brief_dismissed_at. Next-page-load surfaces the new Brief via the context payload. - New my_sea_dismiss_free_draw_brief + my_sea_dismiss_paid_draw_brief POST endpoints stamp the matching User anchor field; return 204. URLs at /gameboard/my-sea/brief/{free,paid}-draw/dismiss. - my_sea view's context computes {free,paid}_draw_brief_payload via the new _tax_brief_payload(user, slug_marker, dismissed_at, dismiss_url) helper — returns the latest TAX_LEDGER Brief's to_banner_dict IF (dismissal anchor is None OR anchor < brief.created_at). Slug discrimination via line__text__contains="FREE DRAW" / "PAID DRAW" (kept the Brief schema flat — only two markers today, non-overlapping wordings). Frontend (apps/dashboard/static/apps/dashboard/note.js): - Brief.showBanner NVM handler now fires a fire-and-forget POST to brief.dismiss_url (if present) before removing the banner. Persistent-NVM kinds (TAX_LEDGER) supply it; transient kinds leave the field empty + the handler no-ops to the existing dismiss-only behavior. CSRF token pulled from the csrftoken cookie. SCSS (static_src/scss/_billboard.scss): - .post-line--system .post-line-text .btn-pri-name — inline emphasis (color: --quaUser, font-weight: 700, font-style: normal) on canonical .btn-primary button labels referenced in @taxman ledger prose. User-spec 2026-05-26 mid-flight clarification: log surface only, not the actual buttons. Templates: - templates/apps/gameboard/my_sea.html: replaces the inline _showFreeDrawLockedBrief({{ next_free_draw_at|date:'c' }}) invocation w. two {% if *_brief_payload %} blocks that json_script the payload + dispatch via a new _showTaxBrief(payload, bannerClass) helper. _postLock updated to call _showFreeDrawLockedBrief(body.free_draw_brief_payload) so freshly-emitted Briefs surface in-place w.o. a reload (same affordance as before, w. server payload). - templates/apps/billboard/post.html: readonly-textarea / system-author-styling / bud-panel-suppression branches all extended to cover post.kind == 'tax_ledger' (parallel to existing 'note_unlock' cases). Line-text rendering uses line.display_text (strips the iso prefix) + treats @taxman the same as @adman (allow HTML rendering for the system-author safe text — required so the .btn-pri-name spans aren't escaped). Tests: UTs (apps/billboard/tests/integrated/test_tax.py — 11 specs): - log_tax_debit creates Post/Line/Brief w. correct kind + author + admin_solicited. - Both slug templates produce expected text (assertions tolerant of inline .btn-pri-name span HTML). - Two spends share one Post w. two distinct Lines (timestamp prefix keeps unique_together happy). - Unknown slug raises KeyError. - post_save guard nukes unsolicited Lines on TAX_LEDGER Posts; solicited Lines survive. - "taxman" is reserved (case-insensitive); get_or_create_taxman idempotent. ITs (apps/gameboard/tests/integrated/test_tax_briefs.py — 13 specs): - my_sea_lock first-card creates TAX_LEDGER Post + Line + Brief; mid-cycle upserts do NOT emit extra debits; clears free_draw_brief_dismissed_at. - my_sea_paid_draw commit creates a separate TAX_LEDGER entry; clears paid_draw_brief_dismissed_at. - Dismiss endpoints stamp the matching User anchor; reject GET (405); require login (302). - my_sea context: *_brief_payload is None until first spend; populated after; suppressed after NVM-dismiss; returns after cycle reset. Existing ITs adjusted (apps/gameboard/tests/integrated/test_views.py): - test_view_triggers_brief_banner_when_active_draw_exists + test_empty_hand_brief_banner_still_triggered + test_view_does_not_trigger_brief_banner_without_active_draw — assertions retargeted from window._showFreeDrawLockedBrief(" to id="id_free_draw_brief_payload" (the new json_script payload tag). - test_brief_next_free_draw_at_uses_user_anchor_not_paid_row — switched from HTML-substring assertion against the rendered ISO (now absent from the page) to a direct response.context["next_free_draw_at"] comparison. Same underlying invariant; cleaner assertion shape. FT (functional_tests/test_bill_post_debits_credits.py — 1 spec): - After two seeded debits, /billboard/post/<uuid>/ renders the "Debits & credits" title, both Line bodies (FREE DRAW + PAID DRAW), @taxman attribution, readonly input w. "No response needed at this time" placeholder, AND verifies the "[iso] " prefix is stripped from display. All 1340 IT+UT green; new FT green; existing FTs unaffected by these changes. Pending follow-up (recorded for next sprint): Per user 2026-05-26 in-flight ask: refactor @adman concerns into apps/billboard/ad.py (paralleling the new apps/billboard/tax.py) — extract Note.grant_if_new's billboard-side concerns (Post/Line/Brief creation, prose templates) out of apps/drama/models.py into the same shape log_tax_debit now follows. Notated for after this sprint lands. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -769,46 +769,36 @@
|
||||
return DAYS[d.getDay()] + ', ' + MONTHS[d.getMonth()]
|
||||
+ ' ' + d.getDate() + ' @ ' + h + ':' + mm + ' ' + ampm;
|
||||
}
|
||||
window._showFreeDrawLockedBrief = function (iso) {
|
||||
// Standard Brief banner — portaled atop the h2 w.
|
||||
// Gaussian-glass bg (see [[note.js]] showBanner). The
|
||||
// next-free-draw moment is passed as an ISO string +
|
||||
// re-used as `created_at` so note.js's `<time
|
||||
// class="note-banner__timestamp">` slot renders the
|
||||
// datetime instead of "Invalid Date" (which it does
|
||||
// for empty/invalid input). The `line_text` carries
|
||||
// only the contextual prose now — the dedicated slot
|
||||
// owns the timestamp display.
|
||||
if (!window.Brief || !Brief.showBanner) return;
|
||||
Brief.showBanner({
|
||||
title: 'Free draw locked',
|
||||
line_text:
|
||||
'Look!—your free draw is locked in. ' +
|
||||
'Next free draw available at:',
|
||||
post_url: '{% url "gameboard" %}',
|
||||
created_at: iso,
|
||||
kind: 'NUDGE',
|
||||
});
|
||||
// Render an arbitrary TAX_LEDGER Brief payload as a slide-down
|
||||
// banner. Server-driven via `free_draw_brief_payload` /
|
||||
// `paid_draw_brief_payload` from the my_sea view's context;
|
||||
// the payload is exactly `Brief.to_banner_dict()` + a populated
|
||||
// `dismiss_url`. `note.js`'s NVM handler POSTs to dismiss_url
|
||||
// on click → server stamps the user's anchor → suppressed on
|
||||
// future loads until the cycle resets. User-spec 2026-05-26.
|
||||
window._showTaxBrief = function (payload, bannerClass) {
|
||||
if (!payload || !window.Brief || !Brief.showBanner) return;
|
||||
Brief.showBanner(payload);
|
||||
var banner = document.querySelector('.note-banner');
|
||||
if (banner) {
|
||||
banner.classList.add('my-sea-locked-banner');
|
||||
// note.js renders the timestamp as `toLocaleDateString`
|
||||
// (e.g., "May 20, 2026") — short-form, no time. Our
|
||||
// use case wants the full `D, M j @ g:i A` shape
|
||||
// (e.g., "Wed, May 20 @ 11:57 PM") so the user sees
|
||||
// both the date AND the precise unlock hour. Overwrite
|
||||
// the rendered text in-place (leaves the `datetime=`
|
||||
// attribute intact for accessibility tooling).
|
||||
banner.classList.add(bannerClass);
|
||||
// Full date+time stamp (D, M j @ g:i A) — note.js
|
||||
// renders a short `toLocaleDateString` by default;
|
||||
// these Briefs want the precise log moment visible.
|
||||
var ts = banner.querySelector('.note-banner__timestamp');
|
||||
if (ts && iso) ts.textContent = _formatTimestamp(iso);
|
||||
// No FYI on this Brief — it's an informational nudge
|
||||
// (locked draw status), not a navigation target. The
|
||||
// NVM button + timestamp slot carry all the affordance
|
||||
// the user needs.
|
||||
var fyi = banner.querySelector('.note-banner__fyi');
|
||||
if (fyi) fyi.remove();
|
||||
if (ts && payload.created_at) {
|
||||
ts.textContent = _formatTimestamp(payload.created_at);
|
||||
}
|
||||
}
|
||||
};
|
||||
// Backwards-compat shim used by `_postLock` below (which
|
||||
// surfaces the freshly-emitted Brief in-place without a page
|
||||
// reload). The view's `my_sea_lock` response carries the new
|
||||
// Brief payload — `_postLock` retrieves the latest one via
|
||||
// a follow-up GET (cheaper than refetching the full page).
|
||||
window._showFreeDrawLockedBrief = function (payload) {
|
||||
window._showTaxBrief(payload, 'my-sea-locked-banner');
|
||||
};
|
||||
function _postLock(hand) {
|
||||
// Returns the parsed JSON body promise so callers (e.g.
|
||||
// _autoDraw) can chain animation onto server commit.
|
||||
@@ -830,9 +820,13 @@
|
||||
}).then(function (r) {
|
||||
return r.ok ? r.json() : null;
|
||||
}).then(function (body) {
|
||||
if (body && body.next_free_draw_at
|
||||
// First-card-of-cycle response carries the freshly-
|
||||
// emitted Brief payload — surface it in-place w.o. a
|
||||
// page reload. Subsequent mid-draw POSTs omit the
|
||||
// payload (no new debit fires) so this no-ops.
|
||||
if (body && body.free_draw_brief_payload
|
||||
&& !document.querySelector('.my-sea-locked-banner')) {
|
||||
window._showFreeDrawLockedBrief(body.next_free_draw_at);
|
||||
window._showFreeDrawLockedBrief(body.free_draw_brief_payload);
|
||||
}
|
||||
return body;
|
||||
});
|
||||
@@ -1015,19 +1009,31 @@
|
||||
{# Tagged w. .my-sea-intro-banner so FTs disambiguate from #}
|
||||
{# any other Briefs on the page. note.js itself is hoisted #}
|
||||
{# to the top of {% block content %} (single load per page). #}
|
||||
{% if active_draw %}
|
||||
{# Iter 4b — saved-draw Brief. Standard portaled banner via #}
|
||||
{# Brief.showBanner (Gaussian-glass bg, atop-h2 positioning); #}
|
||||
{# the on-LOCK-success path inside the picker IIFE calls the #}
|
||||
{# same `window._showFreeDrawLockedBrief` so a freshly-locked #}
|
||||
{# hand gets the identical UX without a page reload. Pass an #}
|
||||
{# ISO timestamp (`|date:'c'`) so note.js's `<time>` slot #}
|
||||
{# parses cleanly instead of rendering "Invalid Date". #}
|
||||
{# @taxman Briefs — server-driven render. Payloads are populated by #}
|
||||
{# the my_sea view's `_tax_brief_payload` helper, which returns #}
|
||||
{# None when the user's NVM-dismissal anchor is more recent than #}
|
||||
{# the latest TAX_LEDGER Brief of that slug (FREE/PAID DRAW). User- #}
|
||||
{# spec 2026-05-26: Briefs persist-dismiss until the next spend #}
|
||||
{# resets the cycle. `dismiss_url` is in the payload — note.js #}
|
||||
{# POSTs it on NVM click + the my_sea_lock/paid_draw views clear #}
|
||||
{# the matching User.*_brief_dismissed_at on the next spend. #}
|
||||
{% if free_draw_brief_payload %}
|
||||
{{ free_draw_brief_payload|json_script:"id_free_draw_brief_payload" }}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
if (window._showFreeDrawLockedBrief) {
|
||||
window._showFreeDrawLockedBrief("{{ next_free_draw_at|date:'c' }}");
|
||||
}
|
||||
var el = document.getElementById('id_free_draw_brief_payload');
|
||||
if (!el || !window._showTaxBrief) return;
|
||||
window._showTaxBrief(JSON.parse(el.textContent), 'my-sea-locked-banner');
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% if paid_draw_brief_payload %}
|
||||
{{ paid_draw_brief_payload|json_script:"id_paid_draw_brief_payload" }}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var el = document.getElementById('id_paid_draw_brief_payload');
|
||||
if (!el || !window._showTaxBrief) return;
|
||||
window._showTaxBrief(JSON.parse(el.textContent), 'my-sea-paid-locked-banner');
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
Reference in New Issue
Block a user