From 4c8da646dada51391226ba4ac10833e6b7f160a8 Mon Sep 17 00:00:00 2001 From: "Tim Evans (slaguru666)" Date: Sun, 24 May 2026 22:02:45 +0100 Subject: [PATCH] =?UTF-8?q?chore:=20initial=20deploy=20=E2=80=94=20SLA=20S?= =?UTF-8?q?hift=20Ledger=20Foundry=20VTT=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + lang/en.json | 3 + module.json | 29 ++ module/sla-shift-ledger.mjs | 584 ++++++++++++++++++++++++++++++++++++ styles/sla-shift-ledger.css | 334 +++++++++++++++++++++ templates/timekeeper.hbs | 67 +++++ 6 files changed, 1018 insertions(+) create mode 100644 .gitignore create mode 100644 lang/en.json create mode 100644 module.json create mode 100644 module/sla-shift-ledger.mjs create mode 100644 styles/sla-shift-ledger.css create mode 100644 templates/timekeeper.hbs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e43b0f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.DS_Store diff --git a/lang/en.json b/lang/en.json new file mode 100644 index 0000000..fcf046c --- /dev/null +++ b/lang/en.json @@ -0,0 +1,3 @@ +{ + "SLAShiftLedger.Title": "SLA Shift Ledger" +} diff --git a/module.json b/module.json new file mode 100644 index 0000000..0b9f9e9 --- /dev/null +++ b/module.json @@ -0,0 +1,29 @@ +{ + "id": "sla-shift-ledger", + "title": "SLA Shift Ledger", + "description": "Tracks SLA campaign day shifts, actor duty schedules, housing tiers, and weekly living-cost ledgers.", + "version": "0.1.0", + "compatibility": { + "minimum": "13", + "verified": "14", + "maximum": "14" + }, + "authors": [ + { + "name": "Tim Evans" + } + ], + "esmodules": [ + "module/sla-shift-ledger.mjs" + ], + "styles": [ + "styles/sla-shift-ledger.css" + ], + "languages": [ + { + "lang": "en", + "name": "English", + "path": "lang/en.json" + } + ] +} diff --git a/module/sla-shift-ledger.mjs b/module/sla-shift-ledger.mjs new file mode 100644 index 0000000..e6fe153 --- /dev/null +++ b/module/sla-shift-ledger.mjs @@ -0,0 +1,584 @@ +const MODULE_ID = "sla-shift-ledger"; +const STATE_KEY = "campaignState"; +const CONFIG_KEY = "billingConfig"; +const ACTOR_FLAG = "actorState"; + +const SHIFTS = [ + { key: "morning", label: "Morning Shift", short: "Morning" }, + { key: "afternoon", label: "Afternoon Shift", short: "Afternoon" }, + { key: "evening", label: "Evening Shift", short: "Evening" }, + { key: "night", label: "Night Shift", short: "Night" } +]; + +const DEFAULT_ACTIVITIES = [ + "Investigation", + "Resting", + "Normal Social Time", + "Transit", + "Fieldwork", + "Surveillance", + "Paperwork", + "Medical Recovery", + "Training", + "Downtime" +]; + +function defaultCampaignState() { + return { + day: 1000, + shift: 0 + }; +} + +function defaultConfig() { + return { + activityOptions: [...DEFAULT_ACTIVITIES], + housingLabels: [ + "Squalid", + "Bare Minimum", + "Cheap Cubicle", + "Basic", + "Steady", + "Comfortable", + "Professional", + "High Grade", + "Luxury", + "Executive" + ], + housingCosts: Array.from({ length: 10 }, (_value, index) => (index + 1) * 50), + livingMultiplier: 0.5, + autoChargeWeekly: true + }; +} + +function defaultActorState() { + return { + housingTier: 1, + schedule: {}, + ledger: [], + lastBilledWeek: 0 + }; +} + +function getState() { + return foundry.utils.mergeObject(defaultCampaignState(), game.settings.get(MODULE_ID, STATE_KEY) ?? {}); +} + +function getConfig() { + const cfg = foundry.utils.mergeObject(defaultConfig(), game.settings.get(MODULE_ID, CONFIG_KEY) ?? {}); + cfg.activityOptions = normalizeActivityOptions(cfg.activityOptions); + cfg.housingLabels = Array.from({ length: 10 }, (_value, index) => String(cfg.housingLabels?.[index] ?? defaultConfig().housingLabels[index]).trim() || defaultConfig().housingLabels[index]); + cfg.housingCosts = Array.from({ length: 10 }, (_value, index) => Math.max(0, Number(cfg.housingCosts?.[index] ?? ((index + 1) * 50)) || 0)); + cfg.livingMultiplier = Math.max(0, Number(cfg.livingMultiplier ?? 0.5) || 0); + cfg.autoChargeWeekly = cfg.autoChargeWeekly !== false; + return cfg; +} + +async function setState(nextState) { + await game.settings.set(MODULE_ID, STATE_KEY, foundry.utils.mergeObject(defaultCampaignState(), nextState ?? {})); +} + +async function setConfig(nextConfig) { + await game.settings.set(MODULE_ID, CONFIG_KEY, foundry.utils.mergeObject(defaultConfig(), nextConfig ?? {})); +} + +function normalizeActivityOptions(value) { + if (Array.isArray(value)) { + const cleaned = value.map((entry) => String(entry ?? "").trim()).filter(Boolean); + return cleaned.length ? cleaned : [...DEFAULT_ACTIVITIES]; + } + const cleaned = String(value ?? "") + .split(/\n+/) + .map((entry) => entry.trim()) + .filter(Boolean); + return cleaned.length ? cleaned : [...DEFAULT_ACTIVITIES]; +} + +function getWeekFromDay(day) { + const numericDay = Math.max(0, Number(day ?? 0) || 0); + return Math.floor(numericDay / 7) + 1; +} + +function getShift(index) { + return SHIFTS[Math.max(0, Math.min(SHIFTS.length - 1, Number(index ?? 0) || 0))] ?? SHIFTS[0]; +} + +function buildCampaignStamp(state = getState()) { + return `Day ${state.day} / ${getShift(state.shift).label}`; +} + +function getActorState(actor) { + return foundry.utils.mergeObject(defaultActorState(), actor.getFlag(MODULE_ID, ACTOR_FLAG) ?? {}); +} + +async function setActorState(actor, nextState) { + await actor.setFlag(MODULE_ID, ACTOR_FLAG, foundry.utils.mergeObject(defaultActorState(), nextState ?? {})); +} + +function normalizeDaySchedule(scheduleMap, day) { + const next = foundry.utils.deepClone(scheduleMap ?? {}); + const key = String(day); + const existing = Array.isArray(next[key]) ? next[key] : []; + next[key] = Array.from({ length: SHIFTS.length }, (_value, index) => String(existing[index] ?? DEFAULT_ACTIVITIES[0] ?? "Investigation")); + return next; +} + +function calculateWeeklyCosts(housingTier, config = getConfig()) { + const index = Math.max(0, Math.min(9, Number(housingTier ?? 1) - 1)); + const housing = Number(config.housingCosts?.[index] ?? 0) || 0; + const living = Math.round(housing * (Number(config.livingMultiplier ?? 0.5) || 0) * 100) / 100; + return { + label: String(config.housingLabels?.[index] ?? `Tier ${index + 1}`), + housing, + living, + total: Math.round((housing + living) * 100) / 100 + }; +} + +function getViewedDay(app) { + const currentDay = Number(getState().day ?? 1000) || 1000; + const stored = Number(app?._slaShiftLedgerViewedDay ?? currentDay) || currentDay; + return Math.max(0, stored); +} + +function getHtmlRoot(html) { + return html instanceof HTMLElement ? html : html?.[0] ?? null; +} + +function getCredits(actor) { + return Number(actor.system?.credits?.value ?? 0) || 0; +} + +async function applyWeeklyBilling(targetWeek = getWeekFromDay(getState().day), { force = false } = {}) { + const config = getConfig(); + const state = getState(); + const actors = game.actors.filter((actor) => actor.type === "character"); + + for (const actor of actors) { + const actorState = getActorState(actor); + if (!force && Number(actorState.lastBilledWeek ?? 0) >= Number(targetWeek)) continue; + + const costs = calculateWeeklyCosts(actorState.housingTier, config); + const currentCredits = getCredits(actor); + const newCredits = Math.round((currentCredits - costs.total) * 100) / 100; + const entry = { + week: targetWeek, + day: state.day, + stamp: buildCampaignStamp(state), + housingTier: actorState.housingTier, + housingLabel: costs.label, + housing: costs.housing, + living: costs.living, + total: costs.total, + balance: newCredits + }; + + const ledger = [entry, ...(Array.isArray(actorState.ledger) ? actorState.ledger : [])].slice(0, 24); + await setActorState(actor, { + ...actorState, + ledger, + lastBilledWeek: Number(targetWeek) + }); + await actor.update({ "system.credits.value": newCredits }); + } +} + +async function advanceShift(delta = 1) { + const state = getState(); + const previousDay = Number(state.day ?? 1000) || 1000; + const previousWeek = getWeekFromDay(previousDay); + let nextShift = Number(state.shift ?? 0) + Number(delta ?? 0); + let nextDay = previousDay; + + while (nextShift < 0) { + nextShift += SHIFTS.length; + nextDay = Math.max(0, nextDay - 1); + } + while (nextShift >= SHIFTS.length) { + nextShift -= SHIFTS.length; + nextDay += 1; + } + + const nextState = { day: nextDay, shift: nextShift }; + await setState(nextState); + + const nextWeek = getWeekFromDay(nextDay); + if (delta > 0 && nextWeek > previousWeek && getConfig().autoChargeWeekly) { + await applyWeeklyBilling(nextWeek); + } + + renderCampaignBanner(); + ui.notifications.info(`Campaign time advanced to ${buildCampaignStamp(nextState)}.`); +} + +function renderCampaignBanner() { + let host = document.querySelector("#sla-shift-ledger-banner"); + if (!host) { + host = document.createElement("section"); + host.id = "sla-shift-ledger-banner"; + host.className = "sla-shift-ledger-banner"; + document.body.appendChild(host); + } + + const state = getState(); + const weekNumber = getWeekFromDay(state.day); + const currentShift = getShift(state.shift); + + host.innerHTML = ` +
+
+
SLA Campaign Clock
+
Day ${state.day} · Week ${weekNumber}
+
${currentShift.label}
+
+
+
+ ${SHIFTS.map((shift, index) => ` +
+ ${shift.short} +
+ `).join("")} +
+
+ ${game.user?.isGM ? ` + + + + ` : ""} +
+
+
+ `; + + if (game.user?.isGM) { + host.querySelector("[data-action='prev']")?.addEventListener("click", () => advanceShift(-1)); + host.querySelector("[data-action='next']")?.addEventListener("click", () => advanceShift(1)); + host.querySelector("[data-action='open']")?.addEventListener("click", () => SlaShiftLedgerApp.open()); + } +} + +class SlaShiftLedgerApp extends FormApplication { + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + id: "sla-shift-ledger-app", + title: "SLA Shift Ledger", + template: "modules/sla-shift-ledger/templates/timekeeper.hbs", + classes: ["sla-shift-ledger", "mosh", "sheet"], + width: 760, + height: "auto", + submitOnChange: false, + closeOnSubmit: false, + resizable: true + }); + } + + async getData() { + const state = getState(); + const config = getConfig(); + return { + state, + config, + weekNumber: getWeekFromDay(state.day), + currentShiftLabel: getShift(state.shift).label, + activityOptionsText: config.activityOptions.join("\n"), + housingLevels: config.housingCosts.map((cost, index) => ({ tier: index + 1, cost, label: config.housingLabels[index] })), + shifts: SHIFTS.map((shift, index) => ({ + ...shift, + index, + selected: index === Number(state.shift), + active: index === Number(state.shift) + })) + }; + } + + activateListeners(html) { + super.activateListeners(html); + html.find("[data-action='prev']").on("click", () => advanceShift(-1)); + html.find("[data-action='next']").on("click", () => advanceShift(1)); + html.find("[data-action='bill']").on("click", async () => { + await applyWeeklyBilling(getWeekFromDay(getState().day), { force: true }); + ui.notifications.info("Weekly billing applied to all character actors."); + }); + } + + async _updateObject(_event, formData) { + const housingCosts = Array.from({ length: 10 }, (_value, index) => Math.max(0, Number(formData[`housingCost${index + 1}`] ?? 0) || 0)); + const housingLabels = Array.from({ length: 10 }, (_value, index) => String(formData[`housingLabel${index + 1}`] ?? "").trim() || defaultConfig().housingLabels[index]); + await setState({ + day: Math.max(0, Number(formData.day ?? 1000) || 1000), + shift: Math.max(0, Math.min(3, Number(formData.shift ?? 0) || 0)) + }); + await setConfig({ + activityOptions: normalizeActivityOptions(formData.activityOptions), + housingLabels, + housingCosts, + livingMultiplier: Math.max(0, Number(formData.livingMultiplier ?? 0.5) || 0.5), + autoChargeWeekly: String(formData.autoChargeWeekly) !== "false" + }); + renderCampaignBanner(); + ui.notifications.info("SLA Shift Ledger saved."); + } + + static open() { + return new SlaShiftLedgerApp().render(true); + } +} + +function installSceneButton(_app, html) { + if (!game.user?.isGM) return; + const root = getHtmlRoot(html); + const directoryHeader = root?.querySelector(".directory-header"); + if (!directoryHeader) return; + let header = directoryHeader.querySelector(".header-actions, .action-buttons"); + if (!header) { + header = document.createElement("div"); + header.className = "header-actions sla-shift-ledger-fallback-actions"; + directoryHeader.appendChild(header); + } + if (!header || header.querySelector(".sla-shift-ledger-open")) return; + const button = document.createElement("button"); + button.type = "button"; + button.className = "sla-shift-ledger-open"; + button.innerHTML = ` Shift Ledger`; + button.addEventListener("click", () => SlaShiftLedgerApp.open()); + header.appendChild(button); +} + +function installSceneButtonFromSidebarTab(app, html) { + const root = getHtmlRoot(html); + const tabName = app?.tabName ?? root?.dataset?.tab ?? ""; + if (tabName !== "scenes") return; + installSceneButton(app, html); +} + +function buildLedgerHtml(actorState) { + const entries = Array.isArray(actorState.ledger) ? actorState.ledger : []; + if (!entries.length) { + return `
No weekly charges have been logged yet.
`; + } + return entries.map((entry) => ` +
+ Week ${entry.week} · ${entry.stamp}
+ Housing Tier ${entry.housingTier}${entry.housingLabel ? ` · ${entry.housingLabel}` : ""}: -${entry.housing} cR · Living: -${entry.living} cR · Total -${entry.total} cR
+ Balance after charge: ${entry.balance} cR +
+ `).join(""); +} + +function renderActorTimePanel(app, html) { + if (app.actor?.type !== "character") return; + + const root = getHtmlRoot(html); + const notesTab = root?.querySelector(".tab[data-tab='notes']"); + if (!notesTab || notesTab.querySelector(".sla-shift-ledger-actor-panel")) return; + + const actor = app.actor; + const actorState = getActorState(actor); + const state = getState(); + const config = getConfig(); + const viewedDay = getViewedDay(app); + const schedule = normalizeDaySchedule(actorState.schedule, viewedDay); + const currentDaySchedule = schedule[String(viewedDay)]; + const weeklyCosts = calculateWeeklyCosts(actorState.housingTier, config); + const currentShiftIndex = Number(state.shift ?? 0) || 0; + + const panel = document.createElement("section"); + panel.className = "sla-shift-ledger-actor-panel sla-card"; + panel.innerHTML = ` +
Shift Ledger
+
Track what this operative is doing across the current day and let weekly housing and living costs feed a running ledger.
+
+ Day ${viewedDay} + Week ${getWeekFromDay(viewedDay)} + ${getShift(state.shift).label} + Current Duty: ${currentDaySchedule[currentShiftIndex] ?? config.activityOptions[0]} +
+
+
Daily Schedule
+
+ + + + Viewing Day ${viewedDay} +
+
+ ${SHIFTS.map((shift, index) => ` +
+ + +
+ `).join("")} +
+
+
+
Housing & Cost of Living
+
+
+ + +
+
+ +
${weeklyCosts.label} · Housing ${weeklyCosts.housing} cR · Living ${weeklyCosts.living} cR · Total ${weeklyCosts.total} cR
+
+
+
+ ${game.user?.isGM ? `` : ""} +
+
+
+
Living Cost Ledger
+
+ ${buildLedgerHtml(actorState)} +
+
+ `; + + notesTab.prepend(panel); + + if (!app.isEditable) return; + + panel.querySelectorAll("[data-sla-day-nav]").forEach((button) => { + button.addEventListener("click", () => { + const dir = button.dataset.slaDayNav; + if (dir === "today") { + app._slaShiftLedgerViewedDay = Number(state.day ?? viewedDay) || viewedDay; + } else { + app._slaShiftLedgerViewedDay = Math.max(0, viewedDay + (Number(dir) || 0)); + } + app.render(false); + }); + }); + + panel.querySelectorAll("[data-sla-shift-index]").forEach((select) => { + select.addEventListener("change", async (event) => { + const shiftIndex = Number(event.currentTarget.dataset.slaShiftIndex ?? 0) || 0; + const nextActorState = getActorState(actor); + const nextSchedule = normalizeDaySchedule(nextActorState.schedule, viewedDay); + nextSchedule[String(viewedDay)][shiftIndex] = String(event.currentTarget.value ?? config.activityOptions[0]); + await setActorState(actor, { ...nextActorState, schedule: nextSchedule }); + app.render(false); + }); + }); + + panel.querySelector("[data-sla-housing-tier]")?.addEventListener("change", async (event) => { + const nextActorState = getActorState(actor); + await setActorState(actor, { + ...nextActorState, + housingTier: Math.max(1, Math.min(10, Number(event.currentTarget.value ?? 1) || 1)) + }); + app.render(false); + }); + + panel.querySelector("[data-sla-bill-now]")?.addEventListener("click", async () => { + const nextActorState = getActorState(actor); + const costs = calculateWeeklyCosts(nextActorState.housingTier, getConfig()); + const stateNow = getState(); + const newCredits = Math.round((getCredits(actor) - costs.total) * 100) / 100; + const entry = { + week: getWeekFromDay(stateNow.day), + day: stateNow.day, + stamp: buildCampaignStamp(stateNow), + housingTier: nextActorState.housingTier, + housingLabel: costs.label, + housing: costs.housing, + living: costs.living, + total: costs.total, + balance: newCredits + }; + await setActorState(actor, { + ...nextActorState, + ledger: [entry, ...(Array.isArray(nextActorState.ledger) ? nextActorState.ledger : [])].slice(0, 24), + lastBilledWeek: Math.max(Number(nextActorState.lastBilledWeek ?? 0), getWeekFromDay(stateNow.day)) + }); + await actor.update({ "system.credits.value": newCredits }); + app.render(false); + }); +} + +function renderActorFrontSummary(app, html) { + if (app.actor?.type !== "character") return; + const root = getHtmlRoot(html); + const strip = root?.querySelector(".sla-front-strip"); + if (!strip || strip.querySelector(".sla-shift-ledger-front-panel")) return; + + const actorState = getActorState(app.actor); + const state = getState(); + const config = getConfig(); + const schedule = normalizeDaySchedule(actorState.schedule, state.day); + const currentSchedule = schedule[String(state.day)]; + const currentDuty = currentSchedule[Number(state.shift ?? 0) || 0] ?? config.activityOptions[0]; + const weeklyCosts = calculateWeeklyCosts(actorState.housingTier, config); + + const panel = document.createElement("div"); + panel.className = "sla-shift-ledger-front-panel"; + panel.innerHTML = ` +
+ Day ${state.day} + ${getShift(state.shift).label} + Duty: ${currentDuty} + Housing: Tier ${actorState.housingTier} · ${weeklyCosts.label} + Weekly: ${weeklyCosts.total} cR +
+ `; + strip.appendChild(panel); +} + +Hooks.once("init", () => { + game.settings.register(MODULE_ID, STATE_KEY, { + scope: "world", + config: false, + type: Object, + default: defaultCampaignState() + }); + + game.settings.register(MODULE_ID, CONFIG_KEY, { + scope: "world", + config: false, + type: Object, + default: defaultConfig() + }); + + game.settings.registerMenu(MODULE_ID, "shiftLedgerMenu", { + name: "SLA Shift Ledger", + label: "Open Shift Ledger", + hint: "Track campaign day, shift cadence, schedule options, and living costs.", + icon: "fas fa-calendar-days", + type: SlaShiftLedgerApp, + restricted: true + }); +}); + +Hooks.once("ready", () => { + game.slaShiftLedger = { + open: () => SlaShiftLedgerApp.open(), + getState, + getConfig, + advanceShift, + applyWeeklyBilling + }; + renderCampaignBanner(); +}); + +Hooks.on("renderSceneDirectory", installSceneButton); +Hooks.on("renderSidebarTab", installSceneButtonFromSidebarTab); +Hooks.on("renderActorSheet", renderActorTimePanel); +Hooks.on("renderActorSheet", renderActorFrontSummary); +Hooks.on("renderMothershipActorSheet", renderActorTimePanel); +Hooks.on("renderMothershipActorSheet", renderActorFrontSummary); +Hooks.on("renderMothershipActorSheetDossier", renderActorTimePanel); +Hooks.on("renderMothershipActorSheetDossier", renderActorFrontSummary); +Hooks.on("updateSetting", (setting) => { + if (setting?.namespace === MODULE_ID && [STATE_KEY, CONFIG_KEY].includes(setting?.key)) { + renderCampaignBanner(); + } +}); diff --git a/styles/sla-shift-ledger.css b/styles/sla-shift-ledger.css new file mode 100644 index 0000000..e4ad11d --- /dev/null +++ b/styles/sla-shift-ledger.css @@ -0,0 +1,334 @@ +.sla-shift-ledger-banner { + position: fixed; + top: 6px; + left: 50%; + transform: translateX(-50%); + z-index: 75; + width: min(1100px, calc(100vw - 120px)); + pointer-events: auto; + font-family: "Josefin Sans", "Signika", sans-serif; +} + +.sla-shift-ledger-banner-inner { + display: grid; + grid-template-columns: minmax(240px, 0.85fr) minmax(0, 1.75fr); + gap: 16px; + align-items: center; + padding: 14px 18px; + border-radius: 16px; + border: 1px solid rgba(124, 196, 255, 0.24); + background: + linear-gradient(180deg, rgba(17, 22, 31, 0.96), rgba(12, 16, 23, 0.94)); + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.04); + color: #d9e7f7; +} + +.sla-shift-ledger-banner-meta { + min-width: 0; +} + +.sla-shift-ledger-banner-main { + display: grid; + grid-template-columns: 1fr; + gap: 10px; + align-items: stretch; +} + +.sla-shift-ledger-banner-kicker, +.sla-shift-ledger-kicker { + font-size: 0.72rem; + letter-spacing: 0.18em; + text-transform: uppercase; + color: #7cc4ff; + margin-bottom: 4px; +} + +.sla-shift-ledger-banner-title, +.sla-shift-ledger-state-line { + font-size: 1.1rem; + font-weight: 700; + color: #f4f8fd; +} + +.sla-shift-ledger-banner-subtitle { + font-size: 0.78rem; + color: #9eb5cf; +} + +.sla-shift-ledger-banner-controls { + display: inline-flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + justify-content: flex-start; +} + +.sla-shift-ledger-button { + border: 1px solid rgba(124, 196, 255, 0.4); + border-radius: 999px; + background: rgba(31, 93, 156, 0.24); + color: #e9f4ff; + min-width: 72px; + padding: 8px 12px; + font-size: 0.75rem; + font-weight: 700; + cursor: pointer; + white-space: nowrap; +} + +.sla-shift-ledger-button:hover { + background: rgba(46, 114, 184, 0.34); +} + +.sla-shift-ledger-shifts { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + min-width: 0; + width: 100%; +} + +.sla-shift-ledger-shift, +.sla-shift-ledger-shift-chip { + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.04); + min-height: 56px; + padding: 10px 12px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + font-size: 0.72rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #90a8c2; + line-height: 1.15; + white-space: nowrap; + overflow: hidden; +} + +.sla-shift-ledger-shift.is-active, +.sla-shift-ledger-shift-chip.is-active { + background: linear-gradient(180deg, rgba(77, 149, 221, 0.38), rgba(31, 93, 156, 0.42)); + color: #ffffff; + border-color: rgba(146, 207, 255, 0.7); + box-shadow: 0 0 0 1px rgba(146, 207, 255, 0.12), 0 0 20px rgba(62, 145, 218, 0.22); +} + +.sla-shift-ledger-open { + margin-left: 6px; +} + +.sla-shift-ledger-form, +.sla-shift-ledger-actor-panel { + color: #d9e7f7; +} + +.sla-shift-ledger-form input, +.sla-shift-ledger-form select, +.sla-shift-ledger-form textarea, +.sla-shift-ledger-actor-panel input, +.sla-shift-ledger-actor-panel select, +.sla-shift-ledger-actor-panel textarea { + background: linear-gradient(180deg, rgba(242, 247, 252, 0.98), rgba(229, 236, 245, 0.98)) !important; + color: #112030 !important; + border: 1px solid rgba(89, 118, 152, 0.48) !important; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.78) !important; +} + +.sla-shift-ledger-form input::placeholder, +.sla-shift-ledger-form textarea::placeholder, +.sla-shift-ledger-actor-panel input::placeholder, +.sla-shift-ledger-actor-panel textarea::placeholder { + color: #61758c !important; +} + +.sla-shift-ledger-form option, +.sla-shift-ledger-actor-panel option { + color: #112030; + background: #e7edf5; +} + +.sla-shift-ledger-grid, +.sla-shift-ledger-actor-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.sla-shift-ledger-field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.sla-shift-ledger-field label { + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #8eb4da; +} + +.sla-shift-ledger-field .notes { + margin: 0; + font-size: 0.74rem; + color: #98aac0; +} + +.sla-shift-ledger-banner-card, +.sla-shift-ledger-cost-panel, +.sla-shift-ledger-actor-card { + margin: 12px 0; + padding: 12px; + border-radius: 16px; + border: 1px solid rgba(124, 196, 255, 0.15); + background: linear-gradient(180deg, rgba(17, 22, 31, 0.82), rgba(10, 14, 20, 0.9)); +} + +.sla-shift-ledger-shift-row { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + margin-top: 10px; +} + +.sla-shift-ledger-cost-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 10px; + margin-top: 8px; +} + +.sla-shift-ledger-actions, +.sla-shift-ledger-inline-actions { + margin-top: 14px; + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.sla-shift-ledger-form .sla-shift-ledger-actions button, +.sla-shift-ledger-form .sla-shift-ledger-inline-actions button, +.sla-shift-ledger-actor-panel .sla-shift-ledger-inline-actions button, +.sla-shift-ledger-actor-panel .sla-shift-ledger-day-nav button { + background: linear-gradient(180deg, rgba(68, 138, 212, 0.96), rgba(29, 95, 162, 0.98)) !important; + color: #f5fbff !important; + border: 1px solid rgba(145, 210, 255, 0.56) !important; + box-shadow: 0 6px 16px rgba(16, 50, 89, 0.22), inset 0 1px 0 rgba(255,255,255,0.18) !important; +} + +.sla-shift-ledger-form .sla-shift-ledger-actions button:hover, +.sla-shift-ledger-form .sla-shift-ledger-inline-actions button:hover, +.sla-shift-ledger-actor-panel .sla-shift-ledger-inline-actions button:hover, +.sla-shift-ledger-actor-panel .sla-shift-ledger-day-nav button:hover { + background: linear-gradient(180deg, rgba(87, 157, 230, 0.98), rgba(42, 111, 181, 0.99)) !important; +} + +.sla-shift-ledger-actor-panel { + margin-bottom: 12px; +} + +.sla-shift-ledger-actor-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.sla-shift-ledger-actor-summary { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 8px; +} + +.sla-shift-ledger-day-nav { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin: 8px 0 10px; +} + +.sla-shift-ledger-day-readout { + font-size: 0.8rem; + color: #dcecff; + font-weight: 700; +} + +.sla-shift-ledger-tag { + border-radius: 999px; + padding: 4px 10px; + font-size: 0.72rem; + background: rgba(124, 196, 255, 0.12); + border: 1px solid rgba(124, 196, 255, 0.18); + color: #d7ecff; +} + +.sla-shift-ledger-front-panel { + margin-top: 10px; +} + +.sla-shift-ledger-front-summary { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.sla-shift-ledger-front-summary .sla-shift-ledger-tag { + background: rgba(90, 168, 255, 0.14); +} + +.sla-shift-ledger-ledger { + margin-top: 10px; + max-height: 180px; + overflow: auto; + display: flex; + flex-direction: column; + gap: 8px; +} + +.sla-shift-ledger-ledger-entry { + padding: 8px 10px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); + font-size: 0.78rem; +} + +.sla-shift-ledger-ledger-entry strong { + color: #ffffff; +} + +@media (max-width: 1100px) { + .sla-shift-ledger-banner { + width: min(900px, calc(100vw - 80px)); + } + + .sla-shift-ledger-banner-inner { + grid-template-columns: 1fr; + } +} + +@media (max-width: 900px) { + .sla-shift-ledger-banner { + position: static; + transform: none; + width: auto; + margin: 8px 8px 0; + } + + .sla-shift-ledger-banner-inner { + grid-template-columns: 1fr; + align-items: stretch; + } + + .sla-shift-ledger-shifts { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .sla-shift-ledger-cost-grid, + .sla-shift-ledger-actor-grid, + .sla-shift-ledger-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} diff --git a/templates/timekeeper.hbs b/templates/timekeeper.hbs new file mode 100644 index 0000000..467745e --- /dev/null +++ b/templates/timekeeper.hbs @@ -0,0 +1,67 @@ +
+
+
+ + +
+
+ + +
+
+ +
+
Campaign Cadence
+
Day {{state.day}} · Week {{weekNumber}} · {{currentShiftLabel}}
+
+ {{#each shifts}} +
{{this.short}}
+ {{/each}} +
+
+ +
+ + +

One option per line. These become the schedule choices on each character sheet.

+
+ +
+
Weekly Housing Costs
+
+ {{#each housingLevels}} +
+ + + +
+ {{/each}} +
+
+ +
+
+ + +

`0.5` means living costs are 50% of housing.

+
+
+ + +
+
+ +
+ + + + +
+