diff --git a/module/Apps/StatsViewer.mjs b/module/Apps/StatsViewer.mjs new file mode 100644 index 0000000..24c9405 --- /dev/null +++ b/module/Apps/StatsViewer.mjs @@ -0,0 +1,128 @@ +import { filePath } from "../consts.mjs"; +import { Logger } from "../utils/Logger.mjs"; + +const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api; + +export class StatsViewer extends HandlebarsApplicationMixin(ApplicationV2) { + // #region Options + static DEFAULT_OPTIONS = { + classes: [ + __ID__, + `StatsViewer`, + ], + window: { + title: `Stat Viewer`, + frame: true, + positioned: true, + resizable: true, + minimizable: true, + }, + position: { + width: 475, + height: 315, + }, + actions: {}, + }; + + static PARTS = { + tableSelect: { + template: filePath(`templates/Apps/StatsViewer/tableSelect.hbs`), + }, + dataFilters: { + template: filePath(`templates/Apps/StatsViewer/dataFilters.hbs`), + }, + graph: { + template: filePath(`templates/Apps/StatsViewer/graph.hbs`), + }, + tableOverview: { + template: filePath(`templates/Apps/StatsViewer/dataOverview.hbs`), + }, + }; + // #endregion + + async _onRender(context, options) { + await super._onRender(context, options); + + const { parts } = options; + + if (parts.includes(`tableSelect`)) { + this.element + .querySelector(`[data-application-part="tableSelect"] [data-bind]`) + ?.addEventListener(`change`, this.#bindListener.bind(this)); + }; + }; + + async _preparePartContext(partId) { + const ctx = {}; + + switch (partId) { + case `tableSelect`: { + this.#prepareTableSelectContext(ctx); + break; + }; + }; + + if (import.meta.env.DEV) { + Logger.log(`Context`, ctx); + }; + return ctx; + }; + + _selectedTable; + _selectedSubtable; + async #prepareTableSelectContext(ctx) { + const tables = new Set(); + const subtables = {}; + + for (const tableConfig of CONFIG.StatsDatabase.getTables()) { + const [ table, subtable ] = tableConfig.name.split(`/`); + tables.add(table); + if (subtable?.length > 0) { + subtables[table] ??= []; + subtables[table].push(subtable); + }; + }; + + const tableList = Array.from(tables); + this._selectedTable ??= tableList[0]; + + ctx.table = this._selectedTable; + ctx.tables = tableList; + + const subtableList = subtables[this._selectedTable]; + if (subtableList && !subtableList.includes(this._selectedSubtable)) { + this._selectedSubtable = subtableList[0]; + } + ctx.subtable = this._selectedSubtable; + ctx.subtables = subtableList; + }; + + /** + * @param {Event} event + */ + async #bindListener(event) { + const target = event.target; + const data = target.dataset; + + const binding = data.bind; + if (!binding || !Object.hasOwn(this, binding)) { + return; + }; + + Logger.log(`updating ${binding} value to ${target.value}`); + this[binding] = target.value; + this.render(); + // this.#updatePartContainingElement(target); + }; + + /** + * @param { HTMLElement } element + */ + #updatePartContainingElement(element) { + const partRoot = element.closest(`[data-application-part]`); + if (!partRoot) { return }; + const data = partRoot.dataset; + const partId = data.applicationPart; + this.render({ parts: [partId] }); + }; +}; diff --git a/module/Apps/TestApp.mjs b/module/Apps/TestApp.mjs new file mode 100644 index 0000000..de3d853 --- /dev/null +++ b/module/Apps/TestApp.mjs @@ -0,0 +1,58 @@ +import Chart from "chart.js/auto"; +import { filePath } from "../consts.mjs"; + +const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api; + +const data = [ + { face: 1, count: Math.floor(Math.random() * 50) }, + { face: 2, count: Math.floor(Math.random() * 50) }, + { face: 3, count: Math.floor(Math.random() * 50) }, + { face: 4, count: Math.floor(Math.random() * 50) }, + { face: 5, count: Math.floor(Math.random() * 50) }, + { face: 6, count: Math.floor(Math.random() * 50) }, +]; + +export class TestApp extends HandlebarsApplicationMixin(ApplicationV2) { + // #region Options + static DEFAULT_OPTIONS = { + window: { + title: `Dice Pool`, + frame: true, + positioned: true, + resizable: false, + minimizable: true, + }, + position: { + width: `auto`, + height: `auto`, + }, + actions: { + }, + }; + + static PARTS = { + numberOfDice: { + template: filePath(`templates/Apps/TestApp/main.hbs`), + }, + }; + // #endregion + + _onRender() { + const canvas = this.element.querySelector(`canvas`); + new Chart( + canvas, + { + type: `bar`, + data: { + labels: data.map( r => r.face), + datasets: [ + { + label: `d6 Rolls`, + data: data.map(r => r.count), + }, + ], + }, + }, + ); + }; +}; diff --git a/module/api.mjs b/module/api.mjs new file mode 100644 index 0000000..54847f7 --- /dev/null +++ b/module/api.mjs @@ -0,0 +1,18 @@ +import { StatsViewer } from "./Apps/StatsViewer.mjs"; +import { TestApp } from "./Apps/TestApp.mjs"; + +const { deepFreeze } = foundry.utils; + +Object.defineProperty( + globalThis, + `stats`, + { + value: deepFreeze({ + Apps: { + TestApp, + StatsViewer, + }, + }), + writable: false, + }, +); diff --git a/module/handlebarsHelpers/_index.mjs b/module/handlebarsHelpers/_index.mjs new file mode 100644 index 0000000..6393b4a --- /dev/null +++ b/module/handlebarsHelpers/_index.mjs @@ -0,0 +1,6 @@ +import { options } from "./options.mjs"; + +export default { + // #region Complex + "st-options": options, +}; diff --git a/module/handlebarsHelpers/options.mjs b/module/handlebarsHelpers/options.mjs new file mode 100644 index 0000000..a7e68a8 --- /dev/null +++ b/module/handlebarsHelpers/options.mjs @@ -0,0 +1,33 @@ +/** + * @typedef {object} Option + * @property {string} [label] + * @property {string|number} value + * @property {boolean} [disabled] + */ + +/** + * @param {string | number} selected + * @param {Array`, + ); + }; + return new Handlebars.SafeString(htmlOptions.join(`\n`)); +}; diff --git a/module/hooks/init.mjs b/module/hooks/init.mjs index 066f96a..105c6c0 100644 --- a/module/hooks/init.mjs +++ b/module/hooks/init.mjs @@ -1,8 +1,19 @@ -import { registerMetaSettings } from "../settings/meta.mjs"; +import helpers from "../handlebarsHelpers/_index.mjs"; import { Logger } from "../utils/Logger.mjs"; +import { MemoryDatabase } from "../utils/databases/Memory.mjs"; +import { registerMetaSettings } from "../settings/meta.mjs"; +import { UserFlagDatabase } from "../utils/databases/UserFlag.mjs"; Hooks.on(`init`, () => { Logger.debug(`Initializing`); registerMetaSettings(); + + if (import.meta.env.PROD) { + CONFIG.StatsDatabase = UserFlagDatabase; + } else { + CONFIG.StatsDatabase = MemoryDatabase; + } + + Handlebars.registerHelper(helpers); }); diff --git a/module/settings/meta.mjs b/module/settings/meta.mjs index 25602a3..78f6ad1 100644 --- a/module/settings/meta.mjs +++ b/module/settings/meta.mjs @@ -1,4 +1,11 @@ export function registerMetaSettings() { + game.settings.register(__ID__, `tables`, { + scope: `world`, + type: Array, + config: false, + requiresReload: false, + }); + game.settings.register(__ID__, `data`, { scope: `user`, type: Object, diff --git a/module/utils/databases/Memory.mjs b/module/utils/databases/Memory.mjs new file mode 100644 index 0000000..f0bd675 --- /dev/null +++ b/module/utils/databases/Memory.mjs @@ -0,0 +1,34 @@ +/* eslint-disable no-unused-vars */ +import { Table } from "./model.mjs"; + +export class MemoryDatabase { + static getTables() { + /** @type {Array<{ name: string; }>} */ + return [ + { name: `Dice/d4` }, + { name: `Dice/d6` }, + { name: `Dice/d8` }, + { name: `Dice/d10` }, + { name: `Dice/d12` }, + { name: `Dice/d20` }, + { name: `Dice/d100` }, + { name: `Count of Successes` }, + ]; + }; + + static createRow(table, user, row) {}; + + static getRows(tableId, ...users) { + if (users.length === 0) { users = [game.user] }; + + const datasets = {}; + + return datasets; + }; + + static updateRow(table, user, rowId, changes) {}; + + static deleteRow(table, user, rowId) {}; +}; + +/* eslint-enable no-unused-vars */ diff --git a/module/utils/databases/UserFlag.mjs b/module/utils/databases/UserFlag.mjs new file mode 100644 index 0000000..c448762 --- /dev/null +++ b/module/utils/databases/UserFlag.mjs @@ -0,0 +1,36 @@ +/* eslint-disable no-unused-vars */ +import { Table } from "./model.mjs"; + +const tablesFlag = `tables`; + +export class UserFlagDatabase { + static getTables() { + /** @type {Array<{ name: string; }>} */ + const tableConfig = game.settings.get(__ID__, `tables`); + return tableConfig ?? []; + }; + + static createRow(table, user, row) {}; + + static getRows(tableId, ...users) { + if (users.length === 0) { users = [game.user] }; + + const datasets = {}; + for (const user of users) { + const tables = user.getFlag(__ID__, tablesFlag) ?? {}; + if (tables[tableId] === undefined) { + datasets[user.id] = null; + continue; + }; + + const table = new Table(tables[tableId]); + } + return datasets; + }; + + static updateRow(table, user, rowId, changes) {}; + + static deleteRow(table, user, rowId) {}; +}; + +/* eslint-enable no-unused-vars */ diff --git a/module/utils/databases/model.mjs b/module/utils/databases/model.mjs new file mode 100644 index 0000000..18b52e7 --- /dev/null +++ b/module/utils/databases/model.mjs @@ -0,0 +1,43 @@ +const { fields } = foundry.data; + +// MARK: Table +export class Table extends foundry.abstract.DataModel { + static defineSchema() { + return { + name: new fields.StringField({ + nullable: false, + required: true, + blank: false, + trim: true, + validate(value) { + return !value.match(/[^a-z0-9_\-:]/i); + }, + }), + data: new fields.TypedObjectField(Row), + }; + }; +}; + +// MARK: Row +export class Row extends foundry.abstract.DataModel { + static defineSchema() { + return { + id: new fields.StringField({ + nullable: false, + required: true, + blank: false, + }), + timestamp: new fields.NumberField({ + min: 0, + required: true, + nullable: false, + }), + value: new fields.AnyField(), + private: new fields.BooleanField({ + initial: false, + required: true, + nullable: false, + }), + }; + }; +}; diff --git a/public/module.json b/public/module.json index ad4fb6b..c35b0f4 100644 --- a/public/module.json +++ b/public/module.json @@ -9,5 +9,11 @@ }, "esmodules": [ "./module.mjs" + ], + "styles": [ + { + "src": "styles/main.css", + "layer": "modules.stats-tracker" + } ] } diff --git a/public/styles/Apps/StatsViewer.css b/public/styles/Apps/StatsViewer.css new file mode 100644 index 0000000..eba05c3 --- /dev/null +++ b/public/styles/Apps/StatsViewer.css @@ -0,0 +1,12 @@ +.stat-tracker.StatsViewer { + + .window-content { + gap: 1rem; + } + + [data-application-part="tableSelect"] { + display: flex; + flex-direction: row; + gap: 1rem; + } +} diff --git a/public/styles/main.css b/public/styles/main.css new file mode 100644 index 0000000..2d33098 --- /dev/null +++ b/public/styles/main.css @@ -0,0 +1,3 @@ +@layer resets, elements, components, apps, exceptions; + +@import url("./Apps/StatsViewer.css") layer(apps); diff --git a/public/templates/Apps/StatsViewer/dataFilters.hbs b/public/templates/Apps/StatsViewer/dataFilters.hbs new file mode 100644 index 0000000..91c87fd --- /dev/null +++ b/public/templates/Apps/StatsViewer/dataFilters.hbs @@ -0,0 +1,3 @@ +