From 0df05e0d0b5b6c00f50b9421ee8a56e4cdb91ecf Mon Sep 17 00:00:00 2001 From: Vassili Minaev Date: Mon, 11 May 2026 01:32:22 -0600 Subject: [PATCH] Attendance dialog in End Session flow endSession() now pulls the session roster from Frappe, computes a default-correct attendance proposal from Foundry actor activity, and presents it as a dropdown grid the GM can override. Activity = actor.system._stats.modifiedTime since session_start, OR a non-GM owner currently online. Notice rows are preserved as-is so the player's stated intent is respected; the GM can still flip them manually in the dialog. finalize_attendance runs BEFORE complete_session so a failure there leaves the session Scheduled and re-runnable rather than Completed- with-no-attendance. Co-Authored-By: Claude Opus 4.7 --- scripts/api.js | 15 +++++ scripts/macros.js | 161 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 174 insertions(+), 2 deletions(-) diff --git a/scripts/api.js b/scripts/api.js index c93bf6c..eff982a 100644 --- a/scripts/api.js +++ b/scripts/api.js @@ -147,6 +147,21 @@ export async function completeSession(sessionId) { return result?.message ?? {}; } +export async function getSessionRoster(sessionId) { + if (!sessionId) throw new Error("getSessionRoster requires a session_id."); + const result = await bridgeFetch("get_session_roster", { session_id: sessionId }); + return result?.message ?? {}; +} + +export async function finalizeAttendance(sessionId, attendance) { + if (!sessionId) throw new Error("finalizeAttendance requires a session_id."); + const result = await bridgeFetch("finalize_attendance", { + session_id: sessionId, + attendance: attendance ?? [], + }); + return result?.message ?? {}; +} + /** * Round-trip an empty manifest push to verify URL, secret, world ID, and * Frappe-side enablement. Surfaces the result in the Foundry UI. diff --git a/scripts/macros.js b/scripts/macros.js index be15abe..40c241f 100644 --- a/scripts/macros.js +++ b/scripts/macros.js @@ -10,6 +10,8 @@ import { MODULE_ID } from "./constants.js"; import { completeSession, + finalizeAttendance, + getSessionRoster, listScheduledSessions, pushModuleInventory, pushSnapshot, @@ -22,6 +24,124 @@ function escapeHtml(s) { ); } +/** + * Activity check used by the attendance proposal: an actor counts as "active" + * for this session if its data was modified during the session window, or if + * a non-GM owner is currently online when End Session is run. + */ +function actorWasActive(actor, sessionStartMs) { + if (!actor) return false; + const mod = actor.system?._stats?.modifiedTime ?? actor._stats?.modifiedTime ?? 0; + if (sessionStartMs && mod >= sessionStartMs) return true; + const ownership = actor.ownership ?? {}; + for (const [userId, level] of Object.entries(ownership)) { + if (userId === "default") continue; + if (level < CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER) continue; + const user = game.users.get(userId); + if (user && !user.isGM && user.active) return true; + } + return false; +} + +/** + * Build an attendance proposal from a Frappe roster payload + current + * Foundry state. Returns the same player rows with a `proposed_status` + * field set: + * - Players whose status is anything other than "Signed Up" keep it. + * - "Signed Up" players become "Present" if any of their linked + * characters' actors show activity this session, else "No Show". + * + * The proposal is a default-correct starting point for the GM-facing + * dialog; every row remains overridable. + */ +export function proposeAttendance(roster) { + const sessionStartMs = roster.session_start ? Date.parse(roster.session_start) : 0; + return (roster.players ?? []).map((p) => { + if (p.status !== "Signed Up") { + return { ...p, proposed_status: p.status }; + } + const anyActive = (p.characters ?? []).some((c) => + actorWasActive(game.actors.get(c.foundry_actor_id), sessionStartMs), + ); + return { ...p, proposed_status: anyActive ? "Present" : "No Show" }; + }); +} + +/** + * Show the attendance confirmation dialog. Resolves to the final attendance + * array `[{player, status}]` on Confirm, or null on Cancel. An empty roster + * resolves to `[]` immediately without showing a dialog. + * + * The dropdown is restricted to Present / Notice / No Show — every row must + * be finalized in this flow. The GM can still rerun End Session later if + * they want to revise (the Frappe handler accepts updates). + */ +async function showAttendanceDialog(proposal) { + if (!proposal.length) return []; + + const STATUS_OPTIONS = ["Present", "Notice", "No Show"]; + const rows = proposal + .map((p, i) => { + const characters = + (p.characters ?? []).map((c) => escapeHtml(c.character)).join(", ") || + ""; + const opts = STATUS_OPTIONS.map( + (s) => + ``, + ).join(""); + return ` + + ${escapeHtml(p.label)} + ${characters} + + + + + `; + }) + .join(""); + + return new Promise((resolve) => { + new Dialog( + { + title: "Finalize Attendance", + content: ` +

Confirm each player's attendance for this session.

+ + + + + + + + + ${rows} +
PlayerCharacter(s)Status
+ `, + buttons: { + cancel: { label: "Cancel", callback: () => resolve(null) }, + confirm: { + label: "Confirm", + callback: (html) => { + const result = []; + html.find("select[data-player]").each(function () { + result.push({ + player: $(this).data("player"), + status: $(this).val(), + }); + }); + resolve(result); + }, + }, + }, + default: "confirm", + close: () => resolve(null), + }, + { width: 560 }, + ).render(true); + }); +} + /** * Push snapshots for every PC in the world, in parallel. Returns a summary * with successful/failed counts and the names of any failures so the caller @@ -92,11 +212,24 @@ async function pickSession(sessions) { }); } -function buildSummaryHtml(completion, snapshotResult, moduleResult) { +function summarizeAttendance(attendance) { + const counts = { Present: 0, Notice: 0, "No Show": 0, "Signed Up": 0 }; + for (const a of attendance) counts[a.status] = (counts[a.status] ?? 0) + 1; + const order = ["Present", "Notice", "No Show", "Signed Up"]; + return order + .filter((k) => counts[k]) + .map((k) => `${counts[k]} ${k}`) + .join(", "); +} + +function buildSummaryHtml(completion, snapshotResult, moduleResult, attendance) { const parts = [ `

Session Complete

`, `

${escapeHtml(completion.completed_session)} marked Completed.

`, ]; + if (attendance?.length) { + parts.push(`

Attendance: ${escapeHtml(summarizeAttendance(attendance))}.

`); + } if (completion.next_session) { parts.push( `

Next session scheduled: ${escapeHtml(completion.next_session)} (#${escapeHtml(completion.next_session_number)}).

`, @@ -151,6 +284,30 @@ export async function endSession() { const sessionId = await pickSession(sessions); if (!sessionId) return; + let roster; + try { + roster = await getSessionRoster(sessionId); + } catch (err) { + console.error(`[${MODULE_ID}]`, err); + ui.notifications.error(`Could not fetch session roster: ${err.message}`); + return; + } + + const proposal = proposeAttendance(roster); + const attendance = await showAttendanceDialog(proposal); + if (attendance === null) return; + + // Finalize attendance BEFORE marking the session Completed so that a + // failure here leaves the session Scheduled and re-runnable, rather + // than Completed with no attendance recorded. + try { + await finalizeAttendance(sessionId, attendance); + } catch (err) { + console.error(`[${MODULE_ID}]`, err); + ui.notifications.error(`Could not finalize attendance: ${err.message}`); + return; + } + let completion; try { completion = await completeSession(sessionId); @@ -174,7 +331,7 @@ export async function endSession() { } await ChatMessage.create({ - content: buildSummaryHtml(completion, snapshotResult, moduleResult), + content: buildSummaryHtml(completion, snapshotResult, moduleResult, attendance), speaker: { alias: "Seitime Bridge" }, whisper: ChatMessage.getWhisperRecipients("GM").map((u) => u.id), });