diff --git a/src/macros_new/gowlers-tracking-ledger/foundry.gowlershome.dyndns.org/modules/gowlers-tracking-ledger/scripts/gowlers-tracking-ledger.js b/src/macros_new/gowlers-tracking-ledger/foundry.gowlershome.dyndns.org/modules/gowlers-tracking-ledger/scripts/gowlers-tracking-ledger.js
new file mode 100644
index 00000000..2d6c88e2
--- /dev/null
+++ b/src/macros_new/gowlers-tracking-ledger/foundry.gowlershome.dyndns.org/modules/gowlers-tracking-ledger/scripts/gowlers-tracking-ledger.js
@@ -0,0 +1,995 @@
+
+const MODULE_ID = "gowlers-tracking-ledger";
+const MODULE_VERSION = "0.1.3";
+const TRACK_SETTING = "actorSettings";
+const FLAG_SCOPE = "world";
+const MAX_HISTORY_ROWS = 100;
+const BUTTON_CLASS = "pf1-history-btn";
+const BUTTON_TITLE = "Open Log";
+const BUTTON_ICON = '';
+const BUTTON_STYLE =
+ "position:absolute;right:6px;top:4px;border:none;background:transparent;padding:0;width:18px;height:18px;display:flex;align-items:center;justify-content:center;color:inherit;cursor:pointer;";
+const DEFAULT_TRACKING = Object.freeze({ hp: true, xp: true, currency: true });
+const DEFAULT_CHAT = Object.freeze({ hp: false, xp: false, currency: false });
+const SETTINGS_VERSION = 2;
+const COIN_ORDER = ["pp", "gp", "sp", "cp"];
+const ENCOUNTER_FLAG = "pf1EncounterHistory";
+
+const STAT_CONFIGS = {
+ hp: {
+ id: "hp",
+ label: "HP",
+ flag: "pf1HpHistory",
+ getter: (actor) => readNumericProperty(actor, "system.attributes.hp.value"),
+ equals: (a, b) => Object.is(a, b),
+ diff: (prev, next) => next - prev,
+ formatValue: (value) => `${value}`,
+ formatDiff: (diff) => (diff >= 0 ? `+${diff}` : `${diff}`),
+ clone: (value) => value,
+ },
+ xp: {
+ id: "xp",
+ label: "XP",
+ flag: "pf1XpHistory",
+ getter: (actor) => readNumericProperty(actor, "system.details.xp.value"),
+ equals: (a, b) => Object.is(a, b),
+ diff: (prev, next) => next - prev,
+ formatValue: (value) => `${value}`,
+ formatDiff: (diff) => (diff >= 0 ? `+${diff}` : `${diff}`),
+ clone: (value) => value,
+ },
+ currency: {
+ id: "currency",
+ label: "Currency",
+ flag: "pf1CurrencyHistory",
+ getter: (actor) => readCurrency(actor),
+ equals: currencyEquals,
+ diff: diffCurrency,
+ formatValue: (value) => formatCurrency(value, false),
+ formatDiff: (value) => formatCurrency(value, true),
+ clone: (value) => (value ? { ...value } : value),
+ },
+};
+
+const LOG_TARGETS = [
+ { id: "hp", finder: findHpHeader },
+ { id: "xp", finder: findXpHeader },
+ { id: "currency", finder: findCurrencyHeader },
+];
+
+const SHEET_EVENTS = [
+ "renderActorSheetPFCharacter",
+ "renderActorSheetPFNPC",
+ "renderActorSheetPFNPCLoot",
+ "renderActorSheetPFNPCLite",
+ "renderActorSheetPFTrap",
+ "renderActorSheetPFVehicle",
+ "renderActorSheetPFHaunt",
+ "renderActorSheetPFBasic",
+];
+
+const ledgerState = {
+ baselines: new Map(), // actorId -> { hp, xp, currency }
+ sheetObservers: new Map(), // `${sheetId}-${stat}` -> observer
+ sheetHooks: [],
+ updateHook: null,
+ createHook: null,
+ deleteHook: null,
+ actorSettings: null,
+};
+
+Hooks.once("init", () => {
+ if (game.system.id !== "pf1") return;
+
+ registerSettings();
+ registerSettingsMenu();
+});
+
+Hooks.once("ready", async () => {
+ if (game.system.id !== "pf1") return;
+ await initializeModule();
+});
+
+async function initializeModule() {
+ if (globalThis.GowlersTrackingLedger?.initialized) return;
+
+ await primeAllActors();
+ ledgerState.updateHook = Hooks.on("updateActor", handleActorUpdate);
+ ledgerState.createHook = Hooks.on("createActor", handleCreateActor);
+ ledgerState.deleteHook = Hooks.on("deleteActor", (actor) => ledgerState.baselines.delete(actor.id));
+
+ ledgerState.sheetHooks = SHEET_EVENTS.map((event) =>
+ Hooks.on(event, (sheet) => {
+ const delays = [0, 100, 250];
+ delays.forEach((delay) => setTimeout(() => attachButtons(sheet), delay));
+ })
+ );
+
+ // Track combat encounters
+ Hooks.on("combatStart", (combat) => onCombatStart(combat));
+ Hooks.on("combatEnd", (combat) => onCombatEnd(combat));
+ Hooks.on("updateCombat", (combat) => onCombatUpdate(combat));
+
+ const api = {
+ initialized: true,
+ openConfig: () => new TrackingLedgerConfig().render(true),
+ openConfigForActor: (actorId) => TrackingLedgerConfig.openForActor(actorId),
+ setActorTracking: setActorTracking,
+ getActorTracking,
+ };
+ game.modules.get(MODULE_ID).api = api;
+ globalThis.GowlersTrackingLedger = api;
+}
+
+function registerSettings() {
+ game.settings.register(MODULE_ID, TRACK_SETTING, {
+ scope: "world",
+ config: false,
+ type: Object,
+ default: {},
+ });
+}
+
+function registerSettingsMenu() {
+ game.settings.registerMenu(MODULE_ID, "config", {
+ name: "Gowler's Tracking Ledger",
+ label: "Configure Tracking",
+ hint: "Choose which actors should track HP/XP/Currency logs.",
+ type: TrackingLedgerConfig,
+ restricted: true,
+ icon: "fas fa-book",
+ });
+}
+
+async function handleCreateActor(actor) {
+ await ensureActorConfig(actor);
+ primeActor(actor);
+}
+
+function handleActorUpdate(actor, change, options, userId) {
+ if (options?.[MODULE_ID]) return;
+ if (!(actor instanceof Actor)) return;
+
+ const tracking = getActorTracking(actor.id);
+ if (!tracking) return;
+
+ const baselines = ledgerState.baselines.get(actor.id) ?? {};
+ let changed = false;
+
+ for (const statId of Object.keys(STAT_CONFIGS)) {
+ if (!tracking[statId]) continue;
+ const config = STAT_CONFIGS[statId];
+ const nextValue = config.getter(actor);
+ if (nextValue === null || nextValue === undefined) continue;
+
+ if (baselines[statId] === undefined) {
+ baselines[statId] = config.clone(nextValue);
+ continue;
+ }
+
+ if (config.equals(baselines[statId], nextValue)) continue;
+
+ const previous = baselines[statId];
+ baselines[statId] = config.clone(nextValue);
+ changed = true;
+ recordHistoryEntry(actor, statId, previous, nextValue, userId).catch((err) =>
+ console.error(`Tracking Ledger | Failed to append ${statId} entry`, err)
+ );
+ }
+
+ if (changed) ledgerState.baselines.set(actor.id, baselines);
+}
+
+async function primeAllActors() {
+ const settings = getSettingsCache();
+ let dirty = false;
+ for (const actor of game.actors.contents) {
+ if (!settings[actor.id]) {
+ settings[actor.id] = createActorConfig();
+ dirty = true;
+ }
+ primeActor(actor);
+ }
+ if (dirty) {
+ await saveActorSettings(settings);
+ }
+}
+
+function primeActor(actor, stats = Object.keys(STAT_CONFIGS)) {
+ if (!(actor instanceof Actor)) return;
+ const existing = ledgerState.baselines.get(actor.id) ?? {};
+ for (const statId of stats) {
+ const config = STAT_CONFIGS[statId];
+ const value = config.getter(actor);
+ if (value === null || value === undefined) continue;
+ existing[statId] = config.clone(value);
+ }
+ ledgerState.baselines.set(actor.id, existing);
+}
+
+function attachButtons(sheet) {
+ if (!sheet?.element?.length || !sheet.actor) return;
+ for (const target of LOG_TARGETS) {
+ try {
+ const container = target.finder(sheet.element);
+ if (!container?.length) continue;
+ addButton(container, sheet, target.id);
+ } catch (err) {
+ console.warn(`Tracking Ledger | Failed to add ${target.id} button`, err);
+ }
+ }
+}
+
+function addButton(container, sheet, statId) {
+ if (!container || !container.length || !sheet.actor) return;
+ if (container.find(`.${BUTTON_CLASS}[data-log-target="${statId}"]`).length) return;
+
+ if (container.css("position") === "static") container.css("position", "relative");
+
+ const label = STAT_CONFIGS[statId]?.label ?? statId.toUpperCase();
+ const button = $(`
+
+ `);
+ button.on("click", (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ openHistoryDialog(sheet.actor, statId);
+ });
+
+ container.append(button);
+ observeContainer(container, sheet, statId);
+}
+
+function observeContainer(container, sheet, statId) {
+ const key = `${sheet.id}-${statId}`;
+ if (ledgerState.sheetObservers.has(key)) return;
+ if (!container[0]) return;
+
+ const observer = new MutationObserver(() => addButton(container, sheet, statId));
+ observer.observe(container[0], { childList: true });
+ ledgerState.sheetObservers.set(key, observer);
+
+ const cleanup = () => {
+ observer.disconnect();
+ ledgerState.sheetObservers.delete(key);
+ sheet.element.off("remove", cleanup);
+ };
+ sheet.element.on("remove", cleanup);
+}
+
+function findHpHeader(root) {
+ let header = root.find(".info-box-header.health-details h3").first();
+ if (header.length) return header;
+ header = root.find('[data-tooltip-extended="hit-points"] h3').first();
+ if (header.length) return header;
+ return findHeaderByText(root, "Hit Points");
+}
+
+function findXpHeader(root) {
+ const experienceHeader = root.find(".info-box.experience h5").first();
+ if (experienceHeader.length) return experienceHeader;
+ return findHeaderByText(root, "Experience");
+}
+
+function findCurrencyHeader(root) {
+ const currencySection = root.find('.tab.inventory ol.currency h3').first();
+ if (currencySection.length) return currencySection;
+ return findHeaderByText(root, "Currency");
+}
+
+function findHeaderByText(root, text) {
+ const lower = text.toLowerCase();
+ return root
+ .find("h1, h2, h3, h4, h5, label")
+ .filter((_, el) => el.textContent?.trim().toLowerCase() === lower)
+ .first();
+}
+
+function openHistoryDialog(actor, initialTab = "hp") {
+ if (!actor) return;
+ const content = buildHistoryContent(actor, initialTab);
+
+ const dialog = new Dialog(
+ {
+ title: `${actor.name}: Tracking Log`,
+ content,
+ buttons: { close: { label: "Close" } },
+ },
+ {
+ width: 800,
+ height: "auto",
+ classes: ["pf1-history-dialog"],
+ render: (html) => {
+ // Handle both jQuery objects and DOM elements
+ const $html = $(html);
+ const $root = $html.find('[data-history-root]');
+
+ if (!$root.length) {
+ console.warn("[GowlersTracking] Root not found");
+ return;
+ }
+
+ // Tab switching
+ $root.find('[data-history-tab]').off('click').on('click', function(e) {
+ e.preventDefault();
+ const tabId = $(this).attr('data-history-tab');
+ console.log("[GowlersTracking] Tab clicked:", tabId);
+
+ // Remove active from all tabs and hide all panels
+ $root.find('[data-history-tab]').removeClass('active');
+ $root.find('[data-history-panel]').hide();
+
+ // Add active to clicked tab and show its panel
+ $root.find(`[data-history-tab="${tabId}"]`).addClass('active');
+ $root.find(`[data-history-panel="${tabId}"]`).show();
+ });
+
+ // Add config icon to dialog header (GM only)
+ if (game.user?.isGM) {
+ const $header = $html.closest('.dialog').find('.dialog-header');
+ if ($header.length && !$header.find('[data-history-config-header]').length) {
+ const $configBtn = $('', {
+ type: 'button',
+ class: 'history-config-header-btn',
+ 'data-history-config-header': 'true',
+ title: 'Configure Actor Tracking',
+ html: '',
+ css: {
+ border: 'none',
+ background: 'transparent',
+ color: '#999',
+ cursor: 'pointer',
+ padding: '4px 8px',
+ marginRight: '4px',
+ fontSize: '18px',
+ transition: 'color 0.2s'
+ }
+ });
+
+ $configBtn.on('mouseenter', function() {
+ $(this).css('color', '#333');
+ }).on('mouseleave', function() {
+ $(this).css('color', '#999');
+ }).on('click', function(e) {
+ e.preventDefault();
+ const api = window.GowlersTrackingLedger;
+ if (api?.openConfigForActor) {
+ api.openConfigForActor(actor.id);
+ } else if (api?.openConfig) {
+ api.openConfig();
+ }
+ });
+
+ $header.find('.close').before($configBtn);
+ console.log("[GowlersTracking] Config button added");
+ }
+ }
+ },
+ }
+ );
+
+ dialog.render(true);
+}
+
+function buildHistoryContent(actor, tabArg) {
+ const initialTab = tabArg ?? "hp"; // Explicitly capture the tab parameter
+ console.log("[GowlersTracking] buildHistoryContent called with initialTab:", initialTab);
+ const canConfigure = game.user?.isGM;
+ const configs = [
+ {
+ id: "hp",
+ label: "HP",
+ flag: STAT_CONFIGS.hp.flag,
+ columns: [
+ { label: "Timestamp", render: (entry) => formatDate(entry.timestamp) },
+ { label: "HP", render: (entry) => entry.value },
+ { label: "Δ", render: (entry) => entry.diff },
+ { label: "User", render: (entry) => entry.user ?? "" },
+ { label: "Encounter", render: (entry) => entry.encounterId ? entry.encounterId.slice(0, 8) : "N/A" },
+ ],
+ },
+ {
+ id: "xp",
+ label: "XP",
+ flag: STAT_CONFIGS.xp.flag,
+ columns: [
+ { label: "Timestamp", render: (entry) => formatDate(entry.timestamp) },
+ { label: "XP", render: (entry) => entry.value },
+ { label: "Δ", render: (entry) => entry.diff },
+ { label: "User", render: (entry) => entry.user ?? "" },
+ { label: "Encounter", render: (entry) => entry.encounterId ? entry.encounterId.slice(0, 8) : "N/A" },
+ ],
+ },
+ {
+ id: "currency",
+ label: "Currency",
+ flag: STAT_CONFIGS.currency.flag,
+ columns: [
+ { label: "Timestamp", render: (entry) => formatDate(entry.timestamp) },
+ { label: "Totals", render: (entry) => entry.value },
+ { label: "Δ", render: (entry) => entry.diff },
+ { label: "User", render: (entry) => entry.user ?? "" },
+ { label: "Encounter", render: (entry) => entry.encounterId ? entry.encounterId.slice(0, 8) : "N/A" },
+ ],
+ },
+ {
+ id: "encounters",
+ label: "Encounters",
+ flag: ENCOUNTER_FLAG,
+ columns: [
+ { label: "Encounter ID", render: (entry) => entry.encounterID.slice(0, 8) },
+ { label: "Date", render: (entry) => formatDate(entry.dateCreated) },
+ { label: "Status", render: (entry) => entry.status ?? "unknown" },
+ { label: "Rounds", render: (entry) => entry.rounds || 0 },
+ { label: "Participants", render: (entry) => entry.participants ? entry.participants.length : 0 },
+ ],
+ },
+ ];
+
+ const tabs = configs
+ .map(
+ (cfg) => {
+ const isActive = cfg.id === initialTab ? "active" : "";
+ return `${cfg.label}`;
+ }
+ )
+ .join("");
+
+ const panels = configs
+ .map((cfg) => {
+ const entries = actor.getFlag(FLAG_SCOPE, cfg.flag) ?? [];
+ const display = cfg.id === initialTab ? "block" : "none";
+ const isActive = cfg.id === initialTab ? "active" : "";
+ return `
+ ${renderHistoryTable(entries, cfg.columns, cfg.id)}
+
`;
+ })
+ .join("");
+
+ return `
+
+
+
+ ${panels}
+
+ `;
+}
+
+function renderHistoryTable(entries, columns, id) {
+ if (!entries.length) {
+ return `No ${id.toUpperCase()} history recorded.
`;
+ }
+
+ const rows = entries
+ .map(
+ (entry) => `
+
+ ${columns.map((col) => `| ${col.render(entry) ?? ""} | `).join("")}
+
`
+ )
+ .join("");
+
+ return `
+
+
+ ${columns.map((col) => `| ${col.label} | `).join("")}
+
+
+ ${rows}
+
+
`;
+}
+
+async function recordHistoryEntry(actor, statId, previous, nextValue, userId) {
+ const config = STAT_CONFIGS[statId];
+ if (!config) return;
+
+ const diffValue = config.diff(previous, nextValue);
+ const entry = {
+ timestamp: Date.now(),
+ value: config.formatValue(nextValue),
+ diff: config.formatDiff(diffValue),
+ user: game.users.get(userId)?.name ?? "System",
+ source: "",
+ encounterId: game.combat?.id ?? null, // Track which encounter this change occurred in
+ };
+
+ const existing = (await actor.getFlag(FLAG_SCOPE, config.flag)) ?? [];
+ existing.unshift(entry);
+ if (existing.length > MAX_HISTORY_ROWS) existing.splice(MAX_HISTORY_ROWS);
+
+ await actor.update({ [`flags.${FLAG_SCOPE}.${config.flag}`]: existing }, { [MODULE_ID]: true });
+
+ if (shouldSendChat(actor.id, statId)) {
+ sendChatNotification(statId, actor, previous, nextValue, entry);
+ }
+}
+
+function formatDate(ts) {
+ return ts ? new Date(ts).toLocaleString() : "";
+}
+
+function readNumericProperty(actor, path) {
+ const value = foundry.utils.getProperty(actor, path) ?? foundry.utils.getProperty(actor.system ?? {}, path.replace(/^system\./, ""));
+ if (value === undefined || value === null) return null;
+ const numeric = Number(value);
+ return Number.isFinite(numeric) ? numeric : null;
+}
+
+function readCurrency(actor) {
+ const base =
+ foundry.utils.getProperty(actor, "system.currency") ??
+ foundry.utils.getProperty(actor.system ?? {}, "currency");
+ if (!base) return null;
+ const clone = {};
+ for (const coin of COIN_ORDER) clone[coin] = Number(base[coin] ?? 0);
+ return clone;
+}
+
+function currencyEquals(a, b) {
+ if (!a || !b) return false;
+ return COIN_ORDER.every((coin) => (a[coin] ?? 0) === (b[coin] ?? 0));
+}
+
+function diffCurrency(prev, next) {
+ const diff = {};
+ for (const coin of COIN_ORDER) diff[coin] = (next?.[coin] ?? 0) - (prev?.[coin] ?? 0);
+ return diff;
+}
+
+function formatCurrency(obj = {}, signed = false) {
+ return COIN_ORDER.map((coin) => {
+ const value = Number(obj[coin] ?? 0);
+ if (signed && value === 0) return null;
+ const formatted = signed ? (value > 0 ? `+${value}` : `${value}`) : `${value}`;
+ return `${coin}:${formatted}`;
+ })
+ .filter(Boolean)
+ .join(" ");
+}
+
+function checkboxValue(value, fallback = false) {
+ if (value === undefined || value === null) return fallback;
+ if (Array.isArray(value)) value = value[value.length - 1];
+ if (typeof value === "string") {
+ const normalized = value.toLowerCase();
+ if (["", "false", "0", "off", "no"].includes(normalized)) return false;
+ if (["true", "1", "on", "yes"].includes(normalized)) return true;
+ }
+ return Boolean(value);
+}
+
+function createActorConfig(source = null) {
+ const config = {
+ version: SETTINGS_VERSION,
+ tracking: { ...DEFAULT_TRACKING },
+ chat: { ...DEFAULT_CHAT },
+ };
+ if (!source) return config;
+
+ if (source.version === SETTINGS_VERSION) {
+ config.tracking = { ...config.tracking, ...(source.tracking ?? {}) };
+ config.chat = { ...config.chat, ...(source.chat ?? {}) };
+ return config;
+ }
+
+ // Legacy/bugged data: only preserve explicit true values so defaults re-enable tracking/chat
+ if (source.tracking) {
+ for (const [key, value] of Object.entries(source.tracking)) {
+ if (value === true) config.tracking[key] = true;
+ }
+ }
+ if (source.chat) {
+ for (const [key, value] of Object.entries(source.chat)) {
+ if (value === true) config.chat[key] = true;
+ }
+ }
+ return config;
+}
+
+function loadActorSettings() {
+ const stored = foundry.utils.deepClone(game.settings.get(MODULE_ID, TRACK_SETTING) ?? {});
+ for (const [actorId, cfg] of Object.entries(stored)) {
+ stored[actorId] = createActorConfig(cfg);
+ }
+ return stored;
+}
+
+function getSettingsCache() {
+ if (!ledgerState.actorSettings) {
+ ledgerState.actorSettings = loadActorSettings();
+ }
+ return ledgerState.actorSettings;
+}
+
+async function saveActorSettings(settings) {
+ for (const entry of Object.values(settings)) {
+ if (!entry) continue;
+ entry.version = SETTINGS_VERSION;
+ entry.tracking ??= { ...DEFAULT_TRACKING };
+ entry.chat ??= { ...DEFAULT_CHAT };
+ }
+ ledgerState.actorSettings = settings;
+ await game.settings.set(MODULE_ID, TRACK_SETTING, settings);
+}
+
+function getActorConfig(actorId) {
+ const settings = getSettingsCache();
+ if (!settings[actorId]) {
+ settings[actorId] = createActorConfig();
+ saveActorSettings(settings).catch((err) => console.error("Tracking Ledger | Failed to persist actor config", err));
+ }
+ return settings[actorId];
+}
+
+function getActorTracking(actorId) {
+ const entry = getActorConfig(actorId);
+ return { ...entry.tracking };
+}
+
+function getActorChat(actorId) {
+ const entry = getActorConfig(actorId);
+ return { ...entry.chat };
+}
+
+async function ensureActorConfig(actor) {
+ if (!actor?.id) return;
+ const settings = getSettingsCache();
+ if (!settings[actor.id]) {
+ settings[actor.id] = createActorConfig();
+ await saveActorSettings(settings);
+ }
+ return settings[actor.id];
+}
+
+async function setActorTracking(actorId, partial) {
+ if (!actorId) return;
+ const settings = getSettingsCache();
+ const entry = getActorConfig(actorId);
+ entry.tracking = {
+ hp: partial.hp ?? entry.tracking.hp,
+ xp: partial.xp ?? entry.tracking.xp,
+ currency: partial.currency ?? entry.tracking.currency,
+ };
+ await saveActorSettings(settings);
+ const actor = game.actors.get(actorId);
+ if (actor) primeActor(actor, Object.keys(partial));
+}
+
+async function setActorChat(actorId, partial) {
+ if (!actorId) return;
+ const settings = getSettingsCache();
+ const entry = getActorConfig(actorId);
+ entry.chat = {
+ hp: partial.hp ?? entry.chat.hp,
+ xp: partial.xp ?? entry.chat.xp,
+ currency: partial.currency ?? entry.chat.currency,
+ };
+ await saveActorSettings(settings);
+}
+
+function shouldSendChat(actorId, statId) {
+ const chat = getActorChat(actorId);
+ return !!chat[statId];
+}
+
+class TrackingLedgerConfig extends FormApplication {
+ static get defaultOptions() {
+ return foundry.utils.mergeObject(super.defaultOptions, {
+ id: "gowlers-tracking-ledger-config",
+ title: "Gowler's Tracking Ledger",
+ template: `modules/${MODULE_ID}/templates/config.hbs`,
+ width: 600,
+ height: "auto",
+ closeOnSubmit: true,
+ });
+ }
+
+ static PAGE_OPTIONS = [10, 20, "all"];
+ static DEFAULT_PAGE_SIZE = 10;
+ static _lastFilter = "";
+ static _lastPage = 0;
+ static _lastPageSize = 10;
+
+ constructor(...args) {
+ super(...args);
+ this._filter = TrackingLedgerConfig._lastFilter ?? "";
+ this._page = TrackingLedgerConfig._lastPage ?? 0;
+ this._pageSize = TrackingLedgerConfig._lastPageSize ?? TrackingLedgerConfig.DEFAULT_PAGE_SIZE;
+ this._pageMeta = { totalPages: 1, hasPrev: false, hasNext: false };
+ this._actorRefs = null;
+ this._filterDebounceTimer = null;
+ }
+
+ get pageSize() {
+ const size = this._pageSize ?? TrackingLedgerConfig.DEFAULT_PAGE_SIZE;
+ return size === "all" ? Infinity : (Number.isFinite(size) ? size : TrackingLedgerConfig.DEFAULT_PAGE_SIZE);
+ }
+
+ async getData() {
+ const settings = getSettingsCache();
+ const filter = (this._filter ?? "").trim().toLowerCase();
+ if (!this._actorRefs) {
+ this._actorRefs = game.actors.contents
+ .map((actor) => ({ id: actor.id, name: actor.name, nameLower: actor.name.toLowerCase() }))
+ .sort((a, b) => a.name.localeCompare(b.name));
+ }
+ const source = this._actorRefs;
+
+ const filtered = filter ? source.filter((ref) => ref.nameLower.includes(filter)) : source;
+
+ const totalActors = filtered.length;
+ const pageSize = this.pageSize;
+ const totalPages = Math.max(1, Math.ceil(Math.max(totalActors, 1) / pageSize));
+ this._page = Math.min(this._page, totalPages - 1);
+
+ const start = this._page * pageSize;
+ const pageItems = filtered.slice(start, start + pageSize).map((ref) => {
+ const entry = settings[ref.id] ?? createActorConfig();
+ return {
+ id: ref.id,
+ name: ref.name,
+ tracking: { ...entry.tracking },
+ chat: { ...entry.chat },
+ };
+ });
+
+ const showingFrom = totalActors ? start + 1 : 0;
+ const showingTo = totalActors ? start + pageItems.length : 0;
+ const hasPrev = this._page > 0;
+ const hasNext = this._page < totalPages - 1;
+
+ this._pageMeta = { totalPages, hasPrev, hasNext };
+ TrackingLedgerConfig._lastFilter = this._filter;
+ TrackingLedgerConfig._lastPage = this._page;
+ TrackingLedgerConfig._lastPageSize = pageSize;
+
+ const pageOptions = TrackingLedgerConfig.PAGE_OPTIONS.map((value) => ({
+ value,
+ selected: value === pageSize,
+ }));
+
+ const totalPagesDisplay = totalActors ? totalPages : 0;
+
+ return {
+ actors: pageItems,
+ filter: this._filter,
+ page: this._page,
+ pageSize,
+ pageOptions,
+ totalPages,
+ totalPagesDisplay,
+ totalActors,
+ showingFrom,
+ showingTo,
+ hasPrev,
+ hasNext,
+ displayPage: totalActors ? this._page + 1 : 0,
+ };
+ }
+
+ async _updateObject(_event, formData) {
+ const expanded = foundry.utils.expandObject(formData) ?? {};
+ const actorPayload = expanded.actors ?? {};
+ const rows = Object.entries(actorPayload).filter(([, cfg]) => cfg && Object.prototype.hasOwnProperty.call(cfg, "__present"));
+
+ if (!rows.length) return;
+
+ const settings = getSettingsCache();
+ let dirty = false;
+
+ for (const [actorId, cfg] of rows) {
+ const entry = getActorConfig(actorId);
+ const nextTracking = {
+ hp: checkboxValue(cfg.tracking?.hp, false),
+ xp: checkboxValue(cfg.tracking?.xp, false),
+ currency: checkboxValue(cfg.tracking?.currency, false),
+ };
+ const nextChat = {
+ hp: checkboxValue(cfg.chat?.hp, false),
+ xp: checkboxValue(cfg.chat?.xp, false),
+ currency: checkboxValue(cfg.chat?.currency, false),
+ };
+
+ if (
+ entry.tracking.hp !== nextTracking.hp ||
+ entry.tracking.xp !== nextTracking.xp ||
+ entry.tracking.currency !== nextTracking.currency
+ ) {
+ entry.tracking = nextTracking;
+ dirty = true;
+ }
+
+ if (entry.chat.hp !== nextChat.hp || entry.chat.xp !== nextChat.xp || entry.chat.currency !== nextChat.currency) {
+ entry.chat = nextChat;
+ dirty = true;
+ }
+ }
+
+ if (dirty) {
+ await saveActorSettings(settings);
+ }
+ for (const [actorId] of rows) {
+ const actor = game.actors.get(actorId);
+ if (actor) primeActor(actor);
+ }
+ }
+
+ activateListeners(html) {
+ super.activateListeners(html);
+ const filterInput = html.find("[data-filter-input]");
+ filterInput.on("input", (event) => {
+ this._filter = event.currentTarget.value ?? "";
+ this._page = 0;
+ TrackingLedgerConfig._lastFilter = this._filter;
+ TrackingLedgerConfig._lastPage = this._page;
+
+ // Debounce render to preserve focus
+ clearTimeout(this._filterDebounceTimer);
+ this._filterDebounceTimer = setTimeout(() => {
+ this.render(false);
+ }, 300);
+ });
+ // Keep focus on filter input after render
+ filterInput.trigger("focus");
+
+ html.find("[data-page-size]").on("change", (event) => {
+ const value = event.currentTarget.value;
+ if (value === "all") {
+ this._pageSize = "all";
+ } else {
+ const num = Number(value);
+ this._pageSize = Number.isFinite(num) && num > 0 ? num : TrackingLedgerConfig.DEFAULT_PAGE_SIZE;
+ }
+ TrackingLedgerConfig._lastPageSize = this._pageSize;
+ this._page = 0;
+ TrackingLedgerConfig._lastPage = 0;
+ this.render(false);
+ });
+
+ html.find("[data-action=\"page\"]").on("click", (event) => {
+ event.preventDefault();
+ const direction = event.currentTarget.dataset.direction;
+ if (!direction) return;
+ const meta = this._pageMeta ?? { totalPages: 1 };
+ if (direction === "prev" && !meta.hasPrev) return;
+ if (direction === "next" && !meta.hasNext) return;
+ const delta = direction === "next" ? 1 : -1;
+ this._page = Math.min(Math.max(this._page + delta, 0), Math.max(0, meta.totalPages - 1));
+ TrackingLedgerConfig._lastPage = this._page;
+ this.render(false);
+ });
+ }
+
+ static openForActor(actorId) {
+ const actor = game.actors.get(actorId);
+ const filter = actor?.name ?? "";
+ TrackingLedgerConfig._lastFilter = filter;
+ TrackingLedgerConfig._lastPage = 0;
+ const app = new TrackingLedgerConfig();
+ app._filter = filter;
+ app._page = 0;
+ app.render(true);
+ }
+}
+
+/**
+ * Update encounter summary when combat starts
+ */
+async function onCombatStart(combat) {
+ if (!combat) return;
+ for (const combatant of combat.combatants) {
+ const actor = combatant.actor;
+ if (!actor) continue;
+ await updateEncounterSummary(actor, combat, "ongoing");
+ }
+}
+
+/**
+ * Update encounter summary when combat round changes or turns change
+ */
+async function onCombatUpdate(combat) {
+ if (!combat) return;
+ for (const combatant of combat.combatants) {
+ const actor = combatant.actor;
+ if (!actor) continue;
+ await updateEncounterSummary(actor, combat, "ongoing");
+ }
+}
+
+/**
+ * Finalize encounter summary when combat ends
+ */
+async function onCombatEnd(combat) {
+ if (!combat) return;
+ for (const combatant of combat.combatants) {
+ const actor = combatant.actor;
+ if (!actor) continue;
+ await updateEncounterSummary(actor, combat, "ended");
+ }
+}
+
+/**
+ * Update or create encounter summary for an actor
+ */
+async function updateEncounterSummary(actor, combat, status = "ongoing") {
+ if (!actor || !combat) return;
+
+ const existing = (await actor.getFlag(FLAG_SCOPE, ENCOUNTER_FLAG)) ?? [];
+
+ // Find existing entry for this combat
+ let encEntry = existing.find((entry) => entry.encounterID === combat.id);
+
+ if (!encEntry) {
+ encEntry = {
+ encounterID: combat.id,
+ dateCreated: Date.now(),
+ dateUpdated: Date.now(),
+ participants: [],
+ status: status,
+ xpGained: 0,
+ rounds: 0,
+ };
+ existing.unshift(encEntry);
+ } else {
+ encEntry.dateUpdated = Date.now();
+ encEntry.status = status;
+ encEntry.rounds = combat.round || 0;
+ }
+
+ // Update participant list with all combatants
+ const participantIds = new Set();
+ for (const combatant of combat.combatants) {
+ if (combatant.actor) {
+ participantIds.add(combatant.actor.id);
+ }
+ }
+ encEntry.participants = Array.from(participantIds);
+
+ // Keep recent encounters (max 50)
+ if (existing.length > 50) {
+ existing.splice(50);
+ }
+
+ await actor.setFlag(FLAG_SCOPE, ENCOUNTER_FLAG, existing);
+}
+
+function sendChatNotification(statId, actor, previous, nextValue, entry) {
+ const config = STAT_CONFIGS[statId];
+ if (!config) return;
+ const titles = {
+ hp: "HP",
+ xp: "XP",
+ currency: "Currency",
+ };
+ const title = titles[statId] ?? statId.toUpperCase();
+ const prevText = config.formatValue(previous);
+ const nextText = entry.value;
+ const content = `
+ ${title} Log
+ ${actor.name}: ${prevText} -> ${nextText} (${entry.diff})
+ User: ${entry.user ?? "System"}
+ `;
+
+ ChatMessage.create({
+ content,
+ speaker: { alias: "Tracking Ledger" },
+ }).catch((err) => console.error("Tracking Ledger | Failed to post chat notification", err));
+}
diff --git a/src/macros_new/gowlers-tracking-ledger/module.json b/src/macros_new/gowlers-tracking-ledger/module.json
index ccae17ca..a6c13f3f 100644
--- a/src/macros_new/gowlers-tracking-ledger/module.json
+++ b/src/macros_new/gowlers-tracking-ledger/module.json
@@ -3,7 +3,7 @@
"type": "module",
"title": "Gowler's Tracking Ledger",
"description": "Adds HP/XP/Currency log buttons to PF1 sheets and opens the tracking dialog preloaded with the actor's logs.",
- "version": "0.1.0",
+ "version": "0.1.3",
"authors": [
{ "name": "Gowler", "url": "https://foundryvtt.com" }
],
diff --git a/src/macros_new/gowlers-tracking-ledger/scripts/gowlers-tracking-ledger.js b/src/macros_new/gowlers-tracking-ledger/scripts/gowlers-tracking-ledger.js
index 14201832..2d6c88e2 100644
--- a/src/macros_new/gowlers-tracking-ledger/scripts/gowlers-tracking-ledger.js
+++ b/src/macros_new/gowlers-tracking-ledger/scripts/gowlers-tracking-ledger.js
@@ -1,6 +1,6 @@
const MODULE_ID = "gowlers-tracking-ledger";
-const MODULE_VERSION = "0.1.0";
+const MODULE_VERSION = "0.1.3";
const TRACK_SETTING = "actorSettings";
const FLAG_SCOPE = "world";
const MAX_HISTORY_ROWS = 100;
@@ -303,40 +303,33 @@ function openHistoryDialog(actor, initialTab = "hp") {
height: "auto",
classes: ["pf1-history-dialog"],
render: (html) => {
- // Use jQuery for robust event delegation
+ // Handle both jQuery objects and DOM elements
const $html = $(html);
const $root = $html.find('[data-history-root]');
- if (!$root.length) return;
+ if (!$root.length) {
+ console.warn("[GowlersTracking] Root not found");
+ return;
+ }
- // Tab switching with jQuery delegation
- $root.on('click', '[data-history-tab]', function(e) {
+ // Tab switching
+ $root.find('[data-history-tab]').off('click').on('click', function(e) {
e.preventDefault();
- const tabId = $(this).data('history-tab');
+ const tabId = $(this).attr('data-history-tab');
+ console.log("[GowlersTracking] Tab clicked:", tabId);
- // Update tab buttons
+ // Remove active from all tabs and hide all panels
$root.find('[data-history-tab]').removeClass('active');
- $root.find(`[data-history-tab="${tabId}"]`).addClass('active');
-
- // Update panels
$root.find('[data-history-panel]').hide();
- $root.find(`[data-history-panel="${tabId}"]`).show();
- });
- // Config button handler
- $root.on('click', '[data-action="open-config"]', function(e) {
- e.preventDefault();
- const api = window.GowlersTrackingLedger;
- if (api?.openConfigForActor) {
- api.openConfigForActor(actor.id);
- } else if (api?.openConfig) {
- api.openConfig();
- }
+ // Add active to clicked tab and show its panel
+ $root.find(`[data-history-tab="${tabId}"]`).addClass('active');
+ $root.find(`[data-history-panel="${tabId}"]`).show();
});
// Add config icon to dialog header (GM only)
if (game.user?.isGM) {
- const $header = $html.find('.dialog-header');
+ const $header = $html.closest('.dialog').find('.dialog-header');
if ($header.length && !$header.find('[data-history-config-header]').length) {
const $configBtn = $('', {
type: 'button',
@@ -356,12 +349,11 @@ function openHistoryDialog(actor, initialTab = "hp") {
}
});
- $configBtn.hover(
- function() { $(this).css('color', '#333'); },
- function() { $(this).css('color', '#999'); }
- );
-
- $configBtn.click(function(e) {
+ $configBtn.on('mouseenter', function() {
+ $(this).css('color', '#333');
+ }).on('mouseleave', function() {
+ $(this).css('color', '#999');
+ }).on('click', function(e) {
e.preventDefault();
const api = window.GowlersTrackingLedger;
if (api?.openConfigForActor) {
@@ -372,12 +364,9 @@ function openHistoryDialog(actor, initialTab = "hp") {
});
$header.find('.close').before($configBtn);
+ console.log("[GowlersTracking] Config button added");
}
}
-
- // Set initial tab
- $root.find(`[data-history-tab="${initialTab}"]`).addClass('active');
- $root.find(`[data-history-panel="${initialTab}"]`).show();
},
}
);
@@ -385,7 +374,9 @@ function openHistoryDialog(actor, initialTab = "hp") {
dialog.render(true);
}
-function buildHistoryContent(actor, initialTab = "hp") {
+function buildHistoryContent(actor, tabArg) {
+ const initialTab = tabArg ?? "hp"; // Explicitly capture the tab parameter
+ console.log("[GowlersTracking] buildHistoryContent called with initialTab:", initialTab);
const canConfigure = game.user?.isGM;
const configs = [
{
@@ -440,8 +431,10 @@ function buildHistoryContent(actor, initialTab = "hp") {
const tabs = configs
.map(
- (cfg) =>
- `${cfg.label}`
+ (cfg) => {
+ const isActive = cfg.id === initialTab ? "active" : "";
+ return `${cfg.label}`;
+ }
)
.join("");
@@ -449,7 +442,8 @@ function buildHistoryContent(actor, initialTab = "hp") {
.map((cfg) => {
const entries = actor.getFlag(FLAG_SCOPE, cfg.flag) ?? [];
const display = cfg.id === initialTab ? "block" : "none";
- return `
+ const isActive = cfg.id === initialTab ? "active" : "";
+ return `
${renderHistoryTable(entries, cfg.columns, cfg.id)}
`;
})