<html><head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <title>User, License and Activity Analytics</title>
    <style>
        :root {
            --b: #0878d1;
            --n: #073b70;
            --c: #43d8ff;
            --g: #00a66b;
            --r: #ee4562;
            --a: #f59e0b;
            --i: #14213a;
            --m: #64748b;
            --l: #dfe8f2;
            --glass: rgba(255,255,255,.82);
            --sh: 0 18px 45px rgba(35,74,115,.15)
        }

        * {
            box-sizing: border-box
        }

        html {
            scroll-behavior: smooth
        }

        body {
            margin: 0;
            min-height: 100vh;
            padding: 22px;
            font-family: "Segoe UI Variable","Segoe UI",Arial,sans-serif;
            color: var(--i);
            background: radial-gradient(circle at 8% 2%,#d9f2ff 0,transparent 30%),radial-gradient(circle at 95% 12%,#e0e7ff 0,transparent 27%),linear-gradient(145deg,#f8fbff,#edf5fb);
            background-attachment: fixed
        }

        .shell {
            max-width: 1650px;
            margin: auto
        }

        .hero {
            position: relative;
            isolation: isolate;
            overflow: hidden;
            display: flex;
            justify-content: space-between;
            align-items: center;
            gap: 20px;
            padding: 29px 34px;
            border-radius: 24px;
            color: #fff;
            background: linear-gradient(125deg,#052f5b,#086fc5 58%,#12a8de);
            box-shadow: 0 25px 60px rgba(3,84,145,.28)
        }

            .hero:after {
                content: "";
                position: absolute;
                width: 320px;
                height: 320px;
                right: -60px;
                top: -190px;
                border-radius: 50%;
                z-index: -1;
                background: linear-gradient(145deg,#ffffff33,#43d8ff12);
                animation: orb 8s ease-in-out infinite
            }

            .hero h1 {
                margin: 7px 0 5px;
                font-size: 30px
            }

            .hero p {
                margin: 0;
                opacity: .88;
                font-size: 14px
            }

        .eye {
            font-size: 11px;
            font-weight: 800;
            letter-spacing: .17em
        }

        .refresh {
            z-index: 1;
            border: 1px solid #ffffffb3;
            border-radius: 12px;
            padding: 11px 18px;
            background: linear-gradient(145deg,#fff,#edf7ff);
            color: var(--n);
            font-weight: 750;
            cursor: pointer;
            box-shadow: 6px 8px 18px #001f4538;
            transition: .22s
        }

            .refresh:hover {
                transform: translateY(-3px)
            }

            .refresh.loading {
                animation: pulse 1s infinite
            }

        .alert {
            display: none;
            margin-top: 15px;
            padding: 13px;
            border: 1px solid #ffb3c0;
            border-radius: 12px;
            background: #fff0f2e6;
            color: #9f1734
        }

            .alert.show {
                display: block
            }

        .tabs {
            display: flex;
            gap: 8px;
            margin: 18px 0 0;
            padding: 6px;
            width: 100%;
            max-width: 100%;
            border: 1px solid #fff;
            background: #ffffffc4;
            border-radius: 15px;
            box-shadow: 0 10px 25px #234a7317
        }

        .tab-btn {
            flex: 1;
            min-width: 0;
            border: 0;
            border-radius: 11px;
            padding: 11px 18px;
            background: transparent;
            color: var(--m);
            font-weight: 750;
            cursor: pointer;
            transition: .22s
        }

            .tab-btn.active {
                color: #fff;
                background: linear-gradient(145deg,#1187dc,#0568b5);
                box-shadow: 5px 7px 16px #0878d13b
            }

        .tab-page {
            display: none
        }

            .tab-page.active {
                display: block;
                animation: rise .4s ease
            }

        .kpis {
            display: grid;
            grid-template-columns: repeat(3,1fr);
            gap: 18px;
            margin: 19px 0
        }

            .kpis.four {
                grid-template-columns: repeat(4,1fr)
            }

        .tile, .panel {
            position: relative;
            background: var(--glass);
            border: 1px solid #ffffffe8;
            backdrop-filter: blur(18px);
            border-radius: 20px;
            box-shadow: var(--sh),inset 1px 1px 0 #fff;
            animation: rise .55s ease both
        }

        .tile {
            overflow: hidden;
            padding: 22px 23px;
            transition: .28s
        }

            .tile:after {
                content: "";
                position: absolute;
                width: 116px;
                height: 116px;
                right: -42px;
                top: -45px;
                border-radius: 50%;
                background: linear-gradient(145deg,#0878d126,#43d8ff0a)
            }

            .tile:hover {
                transform: perspective(850px) translateY(-7px) rotateX(2deg);
                box-shadow: 0 28px 58px #1a507d36
            }

        .tile-head, .panel-head {
            display: flex;
            align-items: center;
            justify-content: space-between;
            gap: 12px
        }

        .label {
            font-size: 13px;
            font-weight: 700;
            color: var(--m)
        }

        .value {
            font-size: 42px;
            font-weight: 780;
            letter-spacing: -.04em;
            margin: 17px 0 7px
        }

        .meta, .sub {
            font-size: 12px;
            color: var(--m)
        }

        .analytics, .chart-grid {
            display: grid;
            grid-template-columns: 1fr 1fr;
            gap: 18px
        }

        .panel {
            padding: 22px;
            margin-top: 18px
        }

            .panel h2 {
                margin: 0 0 4px;
                font-size: 18px
            }

        .dist {
            display: grid;
            grid-template-columns: 220px 1fr;
            gap: 30px;
            align-items: center;
            min-height: 230px
        }

        .donut {
            width: 192px;
            height: 192px;
            border-radius: 50%;
            position: relative;
            margin: auto;
            background: conic-gradient(var(--g) 0 var(--x),var(--r) var(--x) calc(var(--x) + var(--y)),#dce5ee 0);
            box-shadow: 12px 14px 30px #294d701f,inset -7px -8px 13px #134a7314;
            transition: .8s
        }

            .donut:after {
                content: "";
                position: absolute;
                inset: 34px;
                border-radius: 50%;
                background: linear-gradient(145deg,#fff,#edf5fb)
            }

        .center {
            position: absolute;
            inset: 0;
            z-index: 1;
            display: grid;
            place-content: center;
            text-align: center
        }

            .center strong {
                font-size: 32px
            }

            .center span {
                font-size: 11px;
                color: var(--m)
            }

        .legend {
            display: grid;
            gap: 12px
        }

        .legend-row {
            display: flex;
            align-items: center;
            justify-content: space-between;
            gap: 12px;
            padding: 13px 15px;
            border: 1px solid var(--l);
            border-radius: 13px;
            background: linear-gradient(145deg,#fff,#f3f9fd)
        }

        .legend-label {
            display: flex;
            align-items: center;
            gap: 9px;
            font-weight: 750
        }

        .dot {
            width: 11px;
            height: 11px;
            border-radius: 50%;
            flex: 0 0 auto
        }

        .gauge {
            width: 154px;
            height: 154px;
            border-radius: 50%;
            display: grid;
            place-content: center;
            margin: auto;
            background: radial-gradient(circle,#fff 0 55%,transparent 57%),conic-gradient(var(--g) var(--hd),#e5ecf3 0);
            box-shadow: 14px 17px 35px #1f4b711f
        }

            .gauge strong {
                font-size: 37px
            }

            .gauge span {
                font-size: 11px;
                color: var(--m)
            }

        .chart-wrap {
            position: relative;
            height: 310px;
            margin-top: 12px
        }

            .chart-wrap.tall {
                height: 420px;
                display: flex;
                align-items: center;
                justify-content: center
            }

                .chart-wrap.tall canvas {
                    display: block;
                    margin: auto
                }

        .activity-health-panel {
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            text-align: center
        }

            .activity-health-panel h2 {
                width: 100%;
                text-align: center;
                margin-bottom: 16px
            }

            .activity-health-panel .gauge {
                margin: 0 auto
            }

            .activity-health-panel #healthText {
                width: 100%;
                margin: 18px auto 0;
                text-align: center
            }

        .usage-score-panel {
            display: flex;
            flex-direction: column
        }

            .usage-score-panel .dist {
                flex: 1;
                width: 100%;
                min-height: 420px;
                align-content: center
            }

        .chart-tooltip {
            position: absolute;
            display: none;
            z-index: 4;
            pointer-events: none;
            padding: 8px 11px;
            border-radius: 9px;
            background: #102c49eb;
            color: #fff;
            font-size: 11px;
            transform: translate(-50%,-115%);
            white-space: nowrap
        }

        .chart-summary {
            display: flex;
            gap: 25px;
            flex-wrap: wrap;
            margin-top: 8px
        }

        .summary-item strong {
            font-size: 20px
        }

        .summary-item span {
            display: block;
            font-size: 10px;
            color: var(--m);
            text-transform: uppercase
        }

        .tools {
            display: flex;
            gap: 10px;
            flex-wrap: wrap
        }

        .input, .select {
            height: 41px;
            border: 1px solid #c4d4e2;
            border-radius: 11px;
            background: #ffffffe6;
            padding: 0 13px;
            font: inherit;
            font-size: 13px
        }

        .input {
            min-width: 260px
        }

        .table-wrap {
            margin-top: 18px;
            border: 1px solid var(--l);
            border-radius: 14px;
            overflow: auto;
            background: #ffffffb3;
            -webkit-overflow-scrolling: touch
        }

        table {
            width: 100%;
            min-width: 850px;
            border-collapse: collapse
        }

        th, td {
            padding: 14px 15px;
            border-bottom: 1px solid #e6edf4;
            text-align: left;
            font-size: 13px
        }

        th {
            background: linear-gradient(#f8fbfd,#eef4f8);
            color: #58677c;
            font-size: 11px;
            text-transform: uppercase;
            letter-spacing: .06em
        }

        tbody tr:hover {
            background: #eff8ff
        }

        .badge {
            display: inline-flex;
            padding: 5px 10px;
            border-radius: 99px;
            font-size: 11px;
            font-weight: 750
        }

        .active, .score-high {
            background: #ddf7ec;
            color: #087447
        }

        .inactive, .score-low {
            background: #ffe8ed;
            color: #b21d3b
        }

        .unknown, .score-unknown {
            background: #e9eef4;
            color: #5b6878
        }

        .score-medium {
            background: #fff1cf;
            color: #8a5b00
        }

        .actions {
            display: flex;
            gap: 8px;
            flex-wrap: wrap
        }

        .open-btn, .license-btn {
            border: 1px solid #b8d2e5;
            border-radius: 9px;
            background: linear-gradient(145deg,#fff,#edf7ff);
            color: var(--n);
            padding: 8px 12px;
            font-weight: 750;
            cursor: pointer
        }

        .license-btn {
            border-color: #a9d8c4;
            color: #087248;
            background: linear-gradient(145deg,#fff,#ecfbf4)
        }

        .foot, .pager {
            display: flex;
            justify-content: space-between;
            align-items: center;
            gap: 10px;
            margin-top: 12px;
            color: var(--m);
            font-size: 11px
        }

        .pager-controls {
            display: flex;
            align-items: center;
            gap: 8px
        }

        .pager button {
            border: 1px solid #bfd1e0;
            border-radius: 8px;
            background: #fff;
            color: var(--n);
            padding: 8px 12px;
            font-weight: 700;
            cursor: pointer
        }

            .pager button:disabled {
                opacity: .45;
                cursor: not-allowed
            }

        .empty {
            text-align: center !important;
            padding: 32px !important;
            color: var(--m)
        }

        .license-insights, .activity-insights {
            display: grid;
            grid-template-columns: repeat(3,1fr);
            gap: 14px;
            margin-top: 18px
        }

        .activity-insights {
            grid-template-columns: repeat(4,1fr)
        }

        .insight {
            padding: 17px;
            border: 1px solid #dce8f1;
            border-radius: 15px;
            background: linear-gradient(145deg,#fff,#edf8ff);
            box-shadow: 6px 9px 18px #254f7314;
            min-width: 0
        }

            .insight small {
                display: block;
                color: var(--m);
                font-size: 10px;
                text-transform: uppercase
            }

            .insight strong {
                display: block;
                margin: 7px 0;
                font-size: 17px;
                overflow-wrap: anywhere
            }

        .filterbar {
            display: flex;
            justify-content: space-between;
            align-items: center;
            gap: 14px;
            margin: 18px 0;
            padding: 16px 18px;
            border: 1px solid #fff;
            border-radius: 17px;
            background: #ffffffc9;
            box-shadow: var(--sh)
        }

            .filterbar h2 {
                margin: 0 0 4px
            }

        .module-alert {
            display: none;
            margin-top: 14px;
            padding: 13px;
            border: 1px solid #ffb3c0;
            border-radius: 11px;
            background: #fff0f2;
            color: #971a35
        }

            .module-alert.show {
                display: block
            }

        .module-loading {
            display: none;
            margin-top: 14px;
            padding: 12px 15px;
            border-radius: 11px;
            background: #eaf6ff;
            color: var(--n);
            font-size: 12px;
            font-weight: 700
        }

            .module-loading.show {
                display: block
            }

        .modal-bg {
            display: none;
            position: fixed;
            inset: 0;
            z-index: 999;
            align-items: center;
            justify-content: center;
            padding: 20px;
            background: rgba(25,53,79,.28);
            backdrop-filter: blur(3px)
        }

            .modal-bg.open {
                display: flex
            }

        .modal {
            width: min(760px,100%);
            max-height: 92vh;
            overflow: auto;
            border-radius: 22px;
            background: #fffffff0;
            box-shadow: 0 35px 90px #0e314f59
        }

        .stripe {
            height: 7px;
            background: linear-gradient(90deg,var(--n),var(--b),var(--c))
        }

        .modal-body {
            position: relative;
            padding: 26px
        }

        .close {
            position: absolute;
            right: 17px;
            top: 16px;
            width: 34px;
            height: 34px;
            border: 0;
            border-radius: 50%;
            font-size: 20px
        }

        .license-list {
            display: grid;
            grid-template-columns: repeat(2,minmax(0,1fr));
            gap: 12px;
            margin-top: 16px
        }

        .license-card {
            padding: 17px;
            border: 1px solid #d6e6f1;
            border-radius: 16px;
            background: linear-gradient(145deg,#fffffff5,#e9f7ffdb)
        }

            .license-card h4 {
                margin: 0;
                overflow-wrap: anywhere
            }

        .sku {
            margin-top: 10px;
            font-size: 11px;
            color: var(--m);
            overflow-wrap: anywhere
        }

        .help-heading, .help-label {
            display: inline-flex;
            align-items: center;
            gap: 7px
        }

        .help-button {
            position: relative;
            z-index: 2;
            display: inline-grid;
            place-items: center;
            width: 20px;
            height: 20px;
            flex: 0 0 auto;
            padding: 0;
            border: 1px solid #a9c9df;
            border-radius: 50%;
            background: linear-gradient(145deg,#fff,#eaf5fc);
            color: var(--n);
            font: 800 12px/1 "Segoe UI",Arial,sans-serif;
            cursor: pointer;
            box-shadow: 0 4px 10px #234a7317;
            transition: .2s
        }

            .help-button:hover, .help-button:focus-visible {
                color: #fff;
                background: var(--b);
                border-color: var(--b);
                outline: none;
                transform: translateY(-1px)
            }

        .help-popover {
            display: none;
            position: fixed;
            z-index: 1200;
            width: min(330px,calc(100vw - 24px));
            padding: 13px 15px;
            border: 1px solid #c7ddeb;
            border-radius: 12px;
            background: #fffffff8;
            color: #334155;
            box-shadow: 0 18px 45px rgba(15,55,88,.22);
            font-size: 12px;
            line-height: 1.6
        }

            .help-popover.show {
                display: block;
                animation: rise .18s ease
            }

        @keyframes rise {
            from {
                opacity: 0;
                transform: translateY(14px)
            }
        }

        @keyframes orb {
            50% {
                transform: translateY(18px) scale(1.05)
            }
        }

        @keyframes pulse {
            50% {
                opacity: .62
            }
        }

        @media(max-width:1100px) {
            .analytics, .chart-grid {
                grid-template-columns: 1fr
            }

            .kpis.four {
                grid-template-columns: repeat(2,1fr)
            }

            .license-insights, .activity-insights {
                grid-template-columns: repeat(2,1fr)
            }
        }

        @media(max-width:760px) {
            body {
                padding: 10px
            }

            .hero {
                align-items: flex-start;
                flex-direction: column
            }

            .refresh {
                width: 100%
            }

            .tabs {
                width: 100%;
                overflow: auto
            }

            .tab-btn {
                flex: 1;
                min-width: 105px;
                padding: 10px 8px
            }

            .kpis, .kpis.four {
                grid-template-columns: 1fr
            }

            .dist {
                grid-template-columns: 1fr
            }

            .panel-head, .filterbar {
                align-items: flex-start;
                flex-direction: column
            }

            .tools, .input, .select {
                width: 100%
            }

            .license-insights, .activity-insights {
                grid-template-columns: 1fr
            }

            .chart-wrap {
                height: 270px
            }

                .chart-wrap.tall {
                    height: 335px
                }

            .usage-score-panel .dist {
                min-height: 335px
            }

            .license-list {
                grid-template-columns: 1fr
            }

            .actions {
                flex-direction: column
            }

                .actions button {
                    width: 100%
                }

            .foot, .pager {
                align-items: flex-start;
                flex-direction: column;
                gap: 6px
            }
        }
    </style>
<meta></head>
<body>
    <main class="shell">
        <header class="hero"><div><div class="eye">A PRODUCT BY CRM CRATE ©</div><h1>UserScore Analytics</h1><p>Executive visibility into users, licenses, and activity</p></div><button id="refresh" class="refresh">↻ Refresh</button></header>
        <div id="alert" class="alert"></div>
        <nav class="tabs"><button class="tab-btn active" data-tab="userPage">User Analytics</button><button class="tab-btn" data-tab="licensePage">License Analytics</button><button class="tab-btn" data-tab="activityPage">User Activity</button></nav>
        <section id="userPage" class="tab-page active">
            <section class="kpis"><article class="tile"><div class="label help-label">Total Users <button class="help-button" type="button" data-help="User Analytical Profile records." aria-label="About Total Users">?</button></div><div class="value" id="total">0</div></article><article class="tile"><div class="label help-label">Active Users <button class="help-button" type="button" data-help="Users currently classified as active by UserScore based on the configured activity threshold." aria-label="About Active Users">?</button></div><div class="value" id="active">0</div><div id="activeMeta" class="meta">0% of total users</div></article><article class="tile"><div class="label help-label">Inactive Users <button class="help-button" type="button" data-help="Users currently classified as inactive by UserScore based on the configured activity threshold." aria-label="About Inactive Users">?</button></div><div class="value" id="inactive">0</div><div id="inactiveMeta" class="meta">0% of total users</div></article></section>
            <div class="analytics"><article class="panel"><div class="panel-head"><div><h2 class="help-heading">Activity Distribution <button class="help-button" type="button" data-help="Current user activity classification." aria-label="About Activity Distribution">?</button></h2></div></div><div class="dist"><div id="userDonut" class="donut" style="--x:0deg;--y:0deg"><div class="center"><strong id="donutTotal">0</strong><span>Total users</span></div></div><div class="legend"><div class="legend-row"><span class="legend-label"><i class="dot" style="background:var(--g)"></i>Active</span><b id="activePct">0%</b></div><div class="legend-row"><span class="legend-label"><i class="dot" style="background:var(--r)"></i>Inactive</span><b id="inactivePct">0%</b></div><div class="legend-row" id="unknownRow"><span class="legend-label"><i class="dot" style="background:#8493a6"></i>Status not set</span><b id="unknownPct">0%</b></div></div></div></article><article class="panel activity-health-panel"><h2 class="help-heading">Activity Health <button class="help-button" type="button" data-help="Percentage of users with a defined activity status who are currently classified as active." aria-label="About Activity Health">?</button></h2><div class="gauge" id="gauge" style="--hd:0deg"><strong id="healthValue">0%</strong><span>Active rate</span></div><p id="healthText" class="sub" style="text-align:center"></p></article></div>
            <article class="panel"><div class="panel-head"><div><h2 class="help-heading">Latest Recorded Activity by User <button class="help-button" type="button" data-help="Distribution of analytical profiles by their latest recorded activity date during the last 30 calendar days." aria-label="About Latest Recorded Activity by User">?</button></h2></div></div><div class="chart-summary"><div class="summary-item"><strong id="trendTotal">0</strong><span>Users with latest activity</span></div><div class="summary-item"><strong id="trendPeak">0</strong><span>Highest date count</span></div><div class="summary-item"><strong id="trendAvg">0</strong><span>Average per date</span></div></div><div class="chart-wrap" id="trendWrap"><canvas id="trendChart"></canvas><div id="trendTip" class="chart-tooltip"></div></div></article>
            <article class="panel"><div class="panel-head"><h2>User Analytical Profiles</h2><div class="tools"><input id="userSearch" class="input" placeholder="Search user name"><select id="userFilter" class="select"><option value="all">All statuses</option><option value="active">Active</option><option value="inactive">Inactive</option><option value="unknown">Status not set</option></select></div></div><div class="table-wrap"><table><thead><tr><th>User Name</th><th>Activity Status</th><th>Last Active</th><th>Action</th></tr></thead><tbody id="userRows"></tbody></table></div><div class="foot"><span id="userCount"></span><span id="updated"></span></div></article>
        </section>
        <section id="licensePage" class="tab-page">
            <section class="kpis"><article class="tile"><div class="label help-label">Distinct Assigned SKUs <button class="help-button" type="button" data-help="Distinct SKU records currently available in the UserScore license master." aria-label="About Distinct Assigned SKUs">?</button></div><div class="value" id="licenseTotal">0</div></article><article class="tile"><div class="label help-label">Total User-License Assignments <button class="help-button" type="button" data-help="Combined user-to-SKU assignments across all listed SKUs. A user with multiple SKUs is counted once per assignment." aria-label="About Total User-License Assignments">?</button></div><div class="value" id="licenseUsers">0</div></article><article class="tile"><div class="label help-label">Average Assignments per SKU <button class="help-button" type="button" data-help="Average user assignments per distinct listed SKU." aria-label="About Average Assignments per SKU">?</button></div><div class="value" id="licenseAverage">0</div></article></section>
            <div class="chart-grid">
                <article class="panel"><h2>Users by License</h2><div class="chart-wrap tall" id="licenseBarWrap"><canvas id="licenseBarChart"></canvas><div id="licenseBarTip" class="chart-tooltip"></div></div></article><article class="panel usage-score-panel">
                    <h2 class="help-heading">License Assignment Activity <button class="help-button" type="button" data-help="Activity bands show the percentage of users assigned to each SKU who are classified as active by UserScore. They do not measure direct usage of the licensed product." aria-label="About License Assignment Activity">?</button></h2>
                    <div class="dist"> <div id="scoreDonut" class="donut" style="--x:0deg;--y:0deg"><div class="center"><strong id="scoreTotal">0</strong><span>Classified SKUs</span></div></div><div class="legend"><div class="legend-row"><span class="legend-label"><i class="dot" style="background:var(--g)"></i>High Activity</span><b id="scoreHigh">0%</b></div><div class="legend-row"><span class="legend-label"><i class="dot" style="background:var(--a)"></i>Medium Activity</span><b id="scoreMedium">0%</b></div><div class="legend-row"><span class="legend-label"><i class="dot" style="background:var(--r)"></i>Low Activity</span><b id="scoreLow">0%</b></div></div></div>
                </article>
            </div>
            <div class="license-insights">
                <article class="insight"><div class="label help-label">Most Assigned SKU <button class="help-button" type="button" data-help="The SKU assigned to the highest number of UserScore analytical profiles." aria-label="About Most Assigned SKU">?</button></div><strong id="licenseTop">Not available</strong><span id="licenseTopMeta"></span></article><article class="insight">
                    <div class="label help-label">Top 3 Assignment Concentration <button class="help-button" type="button" data-help="Share of all user-to-SKU assignments held by the top three SKUs." aria-label="About Top 3 Assignment Concentration">?</button></div>
                    <strong id="licenseConcentration">0%</strong>
                </article><article class="insight">
                    <div class="label help-label">SKUs with Assignments <button class="help-button" type="button" data-help="Percentage of listed SKUs assigned to at least one analytical profile." aria-label="About SKUs with Assignments">?</button></div>
                    <strong id="licenseCoverage">0%</strong>
                </article>
            </div>
            <article class="panel"><div class="panel-head"><h2>Licenses</h2><div class="tools"><input id="licenseSearch" class="input" placeholder="Search license or SKU"><select id="licenseFilter" class="select"><option value="all">All activity bands</option><option value="high">High Activity</option><option value="medium">Medium Activity</option><option value="low">Low Activity</option><option value="unknown">Not Classified</option></select></div></div><div class="table-wrap"><table><thead><tr><th>License Name</th><th>SKU ID</th><th>Assigned Profiles</th><th>Activity Band</th></tr></thead><tbody id="licenseRows"></tbody></table></div><div class="foot"><span id="licenseCount"></span></div></article>
        </section>
        <section id="activityPage" class="tab-page">
            <div class="filterbar"><div><h2 class="help-heading">User Activity <button class="help-button" type="button" data-help="Login activity is excluded from activity charts and non-login activity totals." aria-label="About User Activity">?</button></h2></div><select id="activityPeriod" class="select"><option value="today">Today</option><option value="week">This Week</option><option value="month" selected="">This Month</option><option value="year">This Year</option><option value="all">All Time</option></select></div><div id="activityLoading" class="module-loading">Loading Activity Snapshot records...</div><div id="activityError" class="module-alert"></div>
            <section class="kpis four"><article class="tile"><div class="label help-label">Total Activities <button class="help-button" type="button" data-help="Login activity is excluded from this total." aria-label="About Total Activities">?</button></div><div class="value" id="activityTotal">0</div></article><article class="tile"><div class="label help-label">Users with Activity <button class="help-button" type="button" data-help="Distinct analytical profiles with captured non-login activity in the selected period." aria-label="About Users with Activity">?</button></div><div class="value" id="activityUsers">0</div></article><article class="tile"><div class="label help-label">Tables Used <button class="help-button" type="button" data-help="Distinct nonblank Dataverse table names represented by captured activity in the selected period." aria-label="About Tables Used">?</button></div><div class="value" id="activityTables">0</div></article><article class="tile"><div class="label help-label">Login Records <button class="help-button" type="button" data-help="Captured login records in the selected period." aria-label="About Login Records">?</button></div><div class="value" id="loginTotal">0</div></article></section>
            <div class="activity-insights"><article class="insight"><div class="label help-label">Top Activity <button class="help-button" type="button" data-help="Most frequently captured non-login activity type in the selected period." aria-label="About Top Activity">?</button></div><strong id="topActivity">Not available</strong><span id="topActivityMeta"></span></article><article class="insight"><div class="label help-label">Busiest Day <button class="help-button" type="button" data-help="Calendar day with the highest number of captured non-login activities in the selected period." aria-label="About Busiest Day">?</button></div><strong id="busiestDay">Not available</strong><span id="busiestDayMeta"></span></article><article class="insight"><div class="label help-label">Peak Hour <button class="help-button" type="button" data-help="Hour of day with the highest number of captured non-login activities in the selected period." aria-label="About Peak Hour">?</button></div><strong id="peakHour">Not available</strong><span id="peakHourMeta"></span></article><article class="insight"><div class="label help-label">Daily Average <button class="help-button" type="button" data-help="Average captured non-login activities per day that contains at least one activity in the selected period." aria-label="About Daily Average">?</button></div><strong id="dailyAverage">0</strong></article></div>
            <div class="chart-grid"><article class="panel"><h2 class="help-heading">Activities by Type <button class="help-button" type="button" data-help="Captured non-login activity types in the selected period." aria-label="About Activities by Type">?</button></h2><div class="chart-wrap tall" id="typeWrap"><canvas id="typeChart"></canvas><div id="typeTip" class="chart-tooltip"></div></div></article><article class="panel"><h2 class="help-heading">Most Worked Tables <button class="help-button" type="button" data-help="Blank, null, and None table values are excluded." aria-label="About Most Worked Tables">?</button></h2><div class="chart-wrap tall" id="tableWrap"><canvas id="tableChart"></canvas><div id="tableTip" class="chart-tooltip"></div></div></article></div>
            <article class="panel"><h2 class="help-heading">Current Day Activity Comparison <button class="help-button" type="button" data-help="Compares today with the same elapsed time window on each of the previous six calendar days. The green bar represents today. Hover directly over a bar to view details." aria-label="About Current Day Activity Comparison">?</button></h2><div class="chart-summary"><div class="summary-item"><strong id="dailyTodayTotal">0</strong><span>Today so far</span></div><div class="summary-item"><strong id="dailyPreviousAverage">0</strong><span>Previous 6-day average</span></div><div class="summary-item"><strong id="dailyDifference">0</strong><span>Difference vs average</span></div></div><div class="chart-wrap" id="dailyWrap"><canvas id="dailyChart"></canvas><div id="dailyTip" class="chart-tooltip"></div></div></article>
            <article class="panel"><h2>Activity Demographics</h2><div class="table-wrap"><table><thead><tr><th>User Name</th><th>Activity Type</th><th>Activity Time</th><th>Table</th><th>Action</th></tr></thead><tbody id="activityRows"></tbody></table></div><div class="pager"><span id="activityPageInfo"></span><div class="pager-controls"><button id="activityPrev">Previous</button><span id="activityPageNumber"></span><button id="activityNext">Next</button></div></div></article>
            <article class="panel"><h2>Latest Login</h2><div class="table-wrap"><table><thead><tr><th>User Name</th><th>Latest Login Time</th></tr></thead><tbody id="loginRows"></tbody></table></div><div class="pager"><span id="loginPageInfo"></span><div class="pager-controls"><button id="loginPrev">Previous</button><span id="loginPageNumber"></span><button id="loginNext">Next</button></div></div></article>
        </section>
    </main>
    <div id="licenseModal" class="modal-bg"><div class="modal"><div class="stripe"></div><div class="modal-body"><button id="modalClose" class="close">×</button><h2>Assigned Licenses</h2><div id="modalUser" class="sub"></div><div id="modalContent"></div></div></div></div>
    <div id="helpPopover" class="help-popover" role="tooltip"></div>
    <script>
        'use strict';
        const V = 'v9.2', PS = 'cc_useranalyticalprofiles', LMS = 'cc_licensemasters', LS = 'cc_licenses', AS = 'cc_activitysnapshots', E = 'cc_useranalyticalprofile', $ = id => document.getElementById(id), PAGE_SIZE = 10;
        const BATCH_PROCESSOR_TABLE = 'cc_batchprocessor';
        const JOB_STATUS_SUBMITTED = 1;
        const JOB_TYPE_RUAP = 3;
        const JOB_TYPE_RAS = 4;
        let systemRefreshRunning = false;
        let profiles = [], licenses = [], activities = [], logins = [], dailyComparisonActivities = [], activityLoaded = false, activityPage = 1, loginPage = 1;
        function xrm() { try { return window.Xrm || window.parent?.Xrm || window.top?.Xrm || null } catch { return null } }
        function root() { return xrm()?.Utility?.getGlobalContext?.().getClientUrl?.() || location.origin }
        async function createBatchProcessor(jobTypeValue) {
            const crm = xrm();
            if (!crm || !crm.WebApi) {
                throw new Error('The Dataverse Web API is unavailable.');
            }
            const batchProcessorRecord = {
                cc_timestamp: new Date().toISOString(),
                cc_jobstatus: JOB_STATUS_SUBMITTED,
                cc_jobtype: jobTypeValue
            };
            return crm.WebApi.createRecord(
                BATCH_PROCESSOR_TABLE,
                batchProcessorRecord
            );
        }
        async function showSynchronizationNotice() {
            const crm = xrm();
            const title = 'User Activity Synchronization';
            const message =
                'User activities cannot be synchronized in real time. ' +
                'This operation will synchronize activities recorded ' +
                'at least five minutes ago. Any recent activities not ' +
                'included in this run will be synchronized during the ' +
                'next scheduled five-minute cycle.';
            if (
                crm &&
                crm.Navigation &&
                typeof crm.Navigation.openConfirmDialog === 'function'
            ) {
                const confirmation = await crm.Navigation.openConfirmDialog(
                    {
                        title: title,
                        text: message,
                        confirmButtonLabel: 'Continue',
                        cancelButtonLabel: 'Cancel'
                    },
                    {
                        height: 260,
                        width: 520
                    }
                );
                return confirmation.confirmed === true;
            }
            return window.confirm(title + '\n\n' + message);
        }
        async function runSystemRefresh() {
            if (systemRefreshRunning) {
                return;
            }
            const refreshButton = $('refresh');
            const alertBox = $('alert');
            try {
                const confirmed = await showSynchronizationNotice();
                if (!confirmed) {
                    console.log('System refresh was cancelled by the user.');
                    return;
                }
                systemRefreshRunning = true;
                refreshButton.disabled = true;
                refreshButton.classList.add('loading');
                refreshButton.textContent = '↻ Synchronizing users...';
                alertBox.classList.remove('show');
                alertBox.textContent = '';
                const ruapResult = await createBatchProcessor(JOB_TYPE_RUAP);
                console.log('RUAP Batch Processor created:', ruapResult.id);
                refreshButton.textContent = '↻ Synchronizing activities...';
                const rasResult = await createBatchProcessor(JOB_TYPE_RAS);
                console.log('RAS Batch Processor created:', rasResult.id);
                refreshButton.textContent = '↻ Loading analytics...';
                await loadBase();
                console.log('UserScore analytics refresh completed.');
            } catch (error) {
                console.error('UserScore system refresh failed:', error);
                alertBox.textContent =
                    'The synchronization request could not be completed. ' +
                    (error && error.message
                        ? error.message
                        : 'An unexpected error occurred.');
                alertBox.classList.add('show');
                const crm = xrm();
                if (
                    crm &&
                    crm.Navigation &&
                    typeof crm.Navigation.openErrorDialog === 'function'
                ) {
                    await crm.Navigation.openErrorDialog({
                        message:
                            'The synchronization request could not be completed. ' +
                            (error && error.message
                                ? error.message
                                : 'An unexpected error occurred.')
                    });
                }
            } finally {
                systemRefreshRunning = false;
                refreshButton.disabled = false;
                refreshButton.classList.remove('loading');
                refreshButton.textContent = '↻ Refresh';
            }
        }
        function esc(v) { return String(v ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])) }
        function friendly(v) { return String(v || 'Not available').replace(/_+/g, ' ').replace(/\s+/g, ' ').trim() }
        function pct(n, t) { return t ? Math.round(n * 1000 / t) / 10 : 0 }
        function dateText(v) { if (!v) return 'Never'; const d = new Date(v); return Number.isNaN(d.getTime()) ? 'Not available' : d.toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) }
        async function apiGet(url) { const r = await fetch(url, { credentials: 'same-origin', headers: { Accept: 'application/json', 'OData-MaxVersion': '4.0', 'OData-Version': '4.0', Prefer: 'odata.include-annotations="OData.Community.Display.V1.FormattedValue"' } }); if (!r.ok) { let m = ''; try { m = (await r.json())?.error?.message || '' } catch { } throw Error(`Dataverse request failed (${r.status}). ${m}`) } return r.json() }
        async function retrieveAll(set, query) { let url = root().replace(/\/$/, '') + '/api/data/' + V + '/' + set + query, rows = []; while (url) { const r = await apiGet(url); rows.push(...(r.value || [])); url = r['@odata.nextLink'] || null } return rows }
        function activityStatus(r) { const v = r.cc_activitystatus, f = String(r['cc_activitystatus@OData.Community.Display.V1.FormattedValue'] || '').trim().toLowerCase(); if (v === true || v === 1 || String(v).toLowerCase() === 'true' || ['yes', 'active', 'enabled'].includes(f)) return true; if (v === false || v === 0 || String(v).toLowerCase() === 'false' || ['no', 'inactive', 'disabled'].includes(f)) return false; return null }
        function scoreValue(r) {
            const formattedValue = String(
                r['cc_score@OData.Community.Display.V1.FormattedValue'] || ''
            ).trim().toLowerCase();

            const numericValue = Number(r.cc_score);

            if (formattedValue.includes('low')) {
                return 'low';
            }

            if (formattedValue.includes('medium')) {
                return 'medium';
            }

            if (formattedValue.includes('high')) {
                return 'high';
            }

            if (numericValue === 1) {
                return 'low';
            }

            if (numericValue === 2) {
                return 'medium';
            }

            if (numericValue === 3) {
                return 'high';
            }

            return 'unknown';
        }
        async function loadProfiles() { const a = await retrieveAll(PS, '?$select=cc_useranalyticalprofileid,cc_activitystatus,cc_lastactivetime,_cc_user_value&$orderby=cc_name asc'); profiles = a.map(r => ({ id: String(r.cc_useranalyticalprofileid || '').replace(/[{}]/g, '').toLowerCase(), name: r['_cc_user_value@OData.Community.Display.V1.FormattedValue'] || 'User not set', active: activityStatus(r), last: r.cc_lastactivetime })) }
        async function loadLicenseAnalytics() { const a = await retrieveAll(LMS, '?$select=cc_licensemasterid,cc_name,cc_skuid,cc_associatedusercount,cc_score&$orderby=cc_associatedusercount desc'); licenses = a.map(r => ({ name: friendly(r.cc_name), sku: r.cc_skuid || '', users: Number(r.cc_associatedusercount) || 0, score: scoreValue(r) })) }
        async function relatedLicenses(id) { return retrieveAll(LS, '?$select=cc_licenseid,cc_name,cc_skuid&$filter=_cc_userprofile_value eq ' + String(id).replace(/[{}]/g, '') + '&$orderby=cc_name asc') }
        function renderUsers() { const total = profiles.length, active = profiles.filter(x => x.active === true).length, inactive = profiles.filter(x => x.active === false).length, unknown = total - active - inactive, ap = pct(active, total), ip = pct(inactive, total), up = pct(unknown, total), health = pct(active, active + inactive); $('total').textContent = total; $('active').textContent = active; $('inactive').textContent = inactive; $('activeMeta').textContent = ap + '% of total users'; $('inactiveMeta').textContent = ip + '% of total users'; $('donutTotal').textContent = total; $('activePct').textContent = ap + '%'; $('inactivePct').textContent = ip + '%'; $('unknownPct').textContent = up + '%'; $('userDonut').style.setProperty('--x', ap * 3.6 + 'deg'); $('userDonut').style.setProperty('--y', ip * 3.6 + 'deg'); $('gauge').style.setProperty('--hd', health * 3.6 + 'deg'); $('healthValue').textContent = health + '%'; $('healthText').textContent = active + inactive ? `${active} of ${active + inactive} users with a defined status are active.` : 'No defined statuses.'; $('unknownRow').style.display = unknown ? 'flex' : 'none'; renderUserRows(); drawUserTrend() }
        function renderUserRows() { const q = $('userSearch').value.trim().toLowerCase(), f = $('userFilter').value, a = profiles.filter(x => (!q || x.name.toLowerCase().includes(q)) && (f === 'all' || f === 'active' && x.active === true || f === 'inactive' && x.active === false || f === 'unknown' && x.active === null)); $('userRows').innerHTML = a.length ? a.map(x => `<tr><td><b>${esc(x.name)}</b></td><td><span class="badge ${x.active === true ? 'active' : x.active === false ? 'inactive' : 'unknown'}">${x.active === true ? 'Active' : x.active === false ? 'Inactive' : 'Status not set'}</span></td><td>${esc(dateText(x.last))}</td><td><div class="actions"><button class="open-btn" data-open="${x.id}">Open record</button><button class="license-btn" data-license="${x.id}" data-user="${esc(x.name)}">View licenses</button></div></td></tr>`).join('') : '<tr><td colspan="4" class="empty">No users found.</td></tr>'; $('userCount').textContent = `Showing ${a.length} of ${profiles.length} users` }
        function renderLicenses() { const total = licenses.length, sum = licenses.reduce((s, x) => s + x.users, 0), avg = total ? Math.round(sum / total * 10) / 10 : 0, hi = licenses.filter(x => x.score === 'high').length, me = licenses.filter(x => x.score === 'medium').length, lo = licenses.filter(x => x.score === 'low').length, sc = hi + me + lo, sorted = [...licenses].sort((a, b) => b.users - a.users), top3 = sorted.slice(0, 3).reduce((s, x) => s + x.users, 0), coverage = licenses.filter(x => x.users > 0).length; $('licenseTotal').textContent = total; $('licenseUsers').textContent = sum.toLocaleString(); $('licenseAverage').textContent = avg; $('scoreTotal').textContent = sc; $('scoreHigh').textContent = pct(hi, sc) + '%'; $('scoreMedium').textContent = pct(me, sc) + '%'; $('scoreLow').textContent = pct(lo, sc) + '%'; $('scoreDonut').style.background = sc ? `conic-gradient(var(--g) 0 ${pct(hi, sc) * 3.6}deg,var(--a) ${pct(hi, sc) * 3.6}deg ${(pct(hi, sc) + pct(me, sc)) * 3.6}deg,var(--r) 0)` : '#dce5ee'; $('licenseTop').textContent = sorted[0]?.name || 'Not available'; $('licenseTopMeta').textContent = (sorted[0]?.users || 0) + ' assigned profiles'; $('licenseConcentration').textContent = pct(top3, sum) + '%'; $('licenseCoverage').textContent = pct(coverage, total) + '%'; renderLicenseRows(); drawBars('licenseBarChart', 'licenseBarWrap', 'licenseBarTip', sorted.slice(0, 12).map(x => ({ label: x.name, value: x.users }))) }
        function renderLicenseRows() { const q = $('licenseSearch').value.trim().toLowerCase(), f = $('licenseFilter').value, a = licenses.filter(x => (!q || (x.name + ' ' + x.sku).toLowerCase().includes(q)) && (f === 'all' || x.score === f)), labels = { high: 'High Activity', medium: 'Medium Activity', low: 'Low Activity', unknown: 'Not Classified' }; $('licenseRows').innerHTML = a.length ? a.map(x => `<tr><td><b>${esc(x.name)}</b></td><td>${esc(x.sku || 'Not available')}</td><td>${x.users.toLocaleString()}</td><td><span class="badge score-${x.score}">${labels[x.score]}</span></td></tr>`).join('') : '<tr><td colspan="4" class="empty">No licenses found.</td></tr>'; $('licenseCount').textContent = `Showing ${a.length} of ${licenses.length} licenses` }
        const ACTIVITY_NAMES = { 1: 'Create', 2: 'Update', 3: 'Delete', 4: 'Activate', 5: 'Deactivate', 12: 'Merge', 13: 'Assign', 14: 'Share', 15: 'Retrieve', 16: 'Close', 17: 'Cancel', 18: 'Complete', 20: 'Resolve', 21: 'Reopen', 24: 'Qualify', 25: 'Disqualify', 26: 'Submit', 27: 'Reject', 28: 'Approve', 29: 'Invoice', 30: 'Hold', 31: 'Add Member', 32: 'Remove Member', 33: 'Associate Entities', 34: 'Disassociate Entities', 35: 'Add Members', 36: 'Remove Members', 37: 'Add Item', 38: 'Remove Item', 41: 'Set State', 42: 'Renew', 43: 'Revise', 44: 'Win', 45: 'Lose', 46: 'Internal Processing', 47: 'Reschedule', 48: 'Modify Share', 49: 'Unshare', 50: 'Book', 52: 'Add To Queue', 55: 'Assign Role To User', 56: 'Remove Role From User', 64: 'Login', 65: 'User Access via Web Services', 100: 'Delete Entity', 101: 'Delete Attribute', 115: 'Archive', 120: 'Restore' };
        function periodStart(p) { const n = new Date(); if (p === 'all') return null; if (p === 'today') return new Date(n.getFullYear(), n.getMonth(), n.getDate()); if (p === 'week') { const d = new Date(n.getFullYear(), n.getMonth(), n.getDate()); d.setDate(d.getDate() - ((d.getDay() + 6) % 7)); return d } if (p === 'month') return new Date(n.getFullYear(), n.getMonth(), 1); return new Date(n.getFullYear(), 0, 1) }
        async function loadActivity() { const start = periodStart($('activityPeriod').value), filter = start ? '&$filter=cc_activitytime ge ' + start.toISOString() : '', a = await retrieveAll(AS, '?$select=cc_activitysnapshotid,cc_activitytime,cc_activitytype,cc_table,cc_recordlink,_cc_useranalyticalprofile_value' + filter + '&$orderby=cc_activitytime desc'), userNameByProfile = new Map(profiles.map(p => [p.id, p.name])); const mapped = a.map(r => { const profileId = String(r._cc_useranalyticalprofile_value || '').replace(/[{}]/g, '').toLowerCase(); return { id: r.cc_activitysnapshotid, time: r.cc_activitytime, typeValue: Number(r.cc_activitytype), type: r['cc_activitytype@OData.Community.Display.V1.FormattedValue'] || ACTIVITY_NAMES[Number(r.cc_activitytype)] || ('Activity ' + String(r.cc_activitytype ?? 'Unknown')), table: r.cc_table ? friendly(r.cc_table) : '', recordLink: String(r.cc_recordlink || '').trim(), profileId, user: userNameByProfile.get(profileId) || 'User not set' } }); logins = mapped.filter(x => x.typeValue === 64 || x.type.trim().toLowerCase() === 'login'); activities = mapped.filter(x => !(x.typeValue === 64 || x.type.trim().toLowerCase() === 'login')); activityLoaded = true; activityPage = 1; loginPage = 1 }
        async function loadDailyComparison() { const start = new Date(); start.setHours(0, 0, 0, 0); start.setDate(start.getDate() - 6); const a = await retrieveAll(AS, '?$select=cc_activitytime,cc_activitytype&$filter=cc_activitytime ge ' + start.toISOString() + '&$orderby=cc_activitytime asc'); dailyComparisonActivities = a.map(r => ({ time: r.cc_activitytime, typeValue: Number(r.cc_activitytype), type: r['cc_activitytype@OData.Community.Display.V1.FormattedValue'] || ACTIVITY_NAMES[Number(r.cc_activitytype)] || '' })).filter(x => !(x.typeValue === 64 || x.type.trim().toLowerCase() === 'login')) }
        function validTable(v) { const x = String(v ?? '').trim().toLowerCase(); return x !== '' && x !== 'none' && x !== 'null' }
        function grouping(rows, key) { const m = new Map(); rows.forEach(x => { const k = x[key] || 'Not available'; m.set(k, (m.get(k) || 0) + 1) }); return [...m].map(([label, value]) => ({ label, value })).sort((a, b) => b.value - a.value) }
        function renderActivity() { const tableRows = activities.filter(x => validTable(x.table)), users = new Set(activities.map(x => x.profileId).filter(Boolean)), tables = new Set(tableRows.map(x => x.table)), types = grouping(activities, 'type'), tableItems = grouping(tableRows, 'table').slice(0, 12); $('activityTotal').textContent = activities.length.toLocaleString(); $('activityUsers').textContent = users.size.toLocaleString(); $('activityTables').textContent = tables.size.toLocaleString(); $('loginTotal').textContent = logins.length.toLocaleString(); $('typeWrap').style.height = $('tableWrap').clientHeight + 'px'; drawBars('typeChart', 'typeWrap', 'typeTip', types); drawBars('tableChart', 'tableWrap', 'tableTip', tableItems); drawDailyActivity(); renderActivityInsights(types); renderActivityPage(); renderLoginPage() }
        function renderActivityInsights(types) { const day = new Map(), hour = new Map(); activities.forEach(x => { const d = new Date(x.time); if (Number.isNaN(d.getTime())) return; const dk = d.toLocaleDateString([], { year: 'numeric', month: 'short', day: 'numeric' }), hk = d.toLocaleTimeString([], { hour: 'numeric', hour12: true }); day.set(dk, (day.get(dk) || 0) + 1); hour.set(hk, (hour.get(hk) || 0) + 1) }); const ds = [...day].sort((a, b) => b[1] - a[1]), hs = [...hour].sort((a, b) => b[1] - a[1]); $('topActivity').textContent = types[0]?.label || 'Not available'; $('topActivityMeta').textContent = (types[0]?.value || 0) + ' activities'; $('busiestDay').textContent = ds[0]?.[0] || 'Not available'; $('busiestDayMeta').textContent = (ds[0]?.[1] || 0) + ' activities'; $('peakHour').textContent = hs[0]?.[0] || 'Not available'; $('peakHourMeta').textContent = (hs[0]?.[1] || 0) + ' activities'; $('dailyAverage').textContent = day.size ? Math.round(activities.length / day.size * 10) / 10 : 0 }
        function latestLogins() { const m = new Map(); logins.forEach(x => { const key = x.profileId || x.id; if (key && !m.has(key)) m.set(key, x) }); return [...m.values()] }
        function renderActivityPage() { const pages = Math.max(1, Math.ceil(activities.length / PAGE_SIZE)); activityPage = Math.min(activityPage, pages); const from = (activityPage - 1) * PAGE_SIZE, rows = activities.slice(from, from + PAGE_SIZE); $('activityRows').innerHTML = rows.length ? rows.map(x => `<tr><td><b>${esc(x.user)}</b></td><td>${esc(x.type)}</td><td>${esc(dateText(x.time))}</td><td>${esc(x.table || 'Not available')}</td><td>${x.recordLink ? `<button class="open-btn" data-activity-link="${esc(x.recordLink)}">Open Record</button>` : '<span class="sub">Link not available</span>'}</td></tr>`).join('') : '<tr><td colspan="5" class="empty">No activity found.</td></tr>'; $('activityPageInfo').textContent = activities.length ? `Showing ${from + 1}-${Math.min(from + PAGE_SIZE, activities.length)} of ${activities.length} records` : 'Showing 0 records'; $('activityPageNumber').textContent = `Page ${activityPage} of ${pages}`; $('activityPrev').disabled = activityPage <= 1; $('activityNext').disabled = activityPage >= pages }
        function renderLoginPage() { const allRows = latestLogins(), pages = Math.max(1, Math.ceil(allRows.length / PAGE_SIZE)); loginPage = Math.min(loginPage, pages); const from = (loginPage - 1) * PAGE_SIZE, rows = allRows.slice(from, from + PAGE_SIZE); $('loginRows').innerHTML = rows.length ? rows.map(x => `<tr><td><b>${esc(x.user)}</b></td><td>${esc(dateText(x.time))}</td></tr>`).join('') : '<tr><td colspan="2" class="empty">No login records found.</td></tr>'; $('loginPageInfo').textContent = allRows.length ? `Showing ${from + 1}-${Math.min(from + PAGE_SIZE, allRows.length)} of ${allRows.length} users` : 'Showing 0 users'; $('loginPageNumber').textContent = `Page ${loginPage} of ${pages}`; $('loginPrev').disabled = loginPage <= 1; $('loginNext').disabled = loginPage >= pages }
        function canvasSetup(cid, wid) { const cv = $(cid), wr = $(wid), d = devicePixelRatio || 1, w = Math.max(280, wr.clientWidth || 280), h = Math.max(230, wr.clientHeight || 300); cv.width = w * d; cv.height = h * d; cv.style.width = w + 'px'; cv.style.height = h + 'px'; const c = cv.getContext('2d'); c.scale(d, d); return { cv, c, w, h } }
        function drawBars(cid, wid, tid, items) { const { cv, c, w, h } = canvasSetup(cid, wid); c.clearRect(0, 0, w, h); const fontSize = w < 520 ? 9 : 11; c.font = fontSize + 'px Segoe UI'; const maxLabelWidth = items.length ? Math.max(...items.map(x => c.measureText(String(x.label)).width)) : 0, left = Math.max(95, Math.min(w * .32, maxLabelWidth + 24)), right = 52, plotWidth = Math.max(70, w - left - right), max = Math.max(1, ...items.map(x => x.value)), count = Math.max(items.length, 1), gap = count === 1 ? 0 : Math.max(14, Math.min(30, h / (count * 3))), barHeight = Math.max(18, Math.min(72, (h - 36 - gap * (count - 1)) / count)), totalHeight = barHeight * count + gap * (count - 1), startY = Math.max(16, (h - totalHeight) / 2), pts = []; items.forEach((x, i) => { const y = startY + i * (barHeight + gap), bw = plotWidth * x.value / max, label = String(x.label), limit = w < 520 ? 18 : 31, name = label.length > limit ? label.slice(0, limit - 1) + '…' : label; c.textBaseline = 'middle'; c.textAlign = 'right'; c.fillStyle = '#526477'; c.fillText(name, left - 10, y + barHeight / 2); const g = c.createLinearGradient(left, 0, left + plotWidth, 0); g.addColorStop(0, '#0878d1'); g.addColorStop(1, '#43d8ff'); c.fillStyle = g; c.beginPath(); c.roundRect(left, y, Math.max(3, bw), barHeight, 8); c.fill(); c.textAlign = 'left'; c.fillStyle = '#1d3550'; c.fillText(String(x.value), Math.min(w - right + 5, left + bw + 7), y + barHeight / 2); pts.push({ x: left + Math.max(3, bw) / 2, y: y + barHeight / 2, label: x.label, value: x.value }) }); hoverChart(cv, pts, tid) }
        function dailyRange(rows, days) { const end = new Date(); end.setHours(0, 0, 0, 0); const start = new Date(end); start.setDate(end.getDate() - days + 1); const a = []; for (let i = 0; i < days; i++) { const d = new Date(start); d.setDate(start.getDate() + i); a.push({ date: d, label: d.toLocaleDateString([], { month: 'short', day: 'numeric' }), value: 0 }) } rows.forEach(x => { const d = new Date(x.time || x.last); if (Number.isNaN(d.getTime())) return; d.setHours(0, 0, 0, 0); const idx = Math.round((d - start) / 86400000); if (idx >= 0 && idx < a.length) a[idx].value++ }); return a }
        function drawLine(cid, wid, tid, items, xTitle, yTitle) { const { cv, c, w, h } = canvasSetup(cid, wid), p = { l: 52, r: 18, t: 20, b: 52 }, cw = w - p.l - p.r, ch = h - p.t - p.b, max = Math.max(1, ...items.map(x => x.value)), pts = items.map((x, i) => ({ x: p.l + cw * i / Math.max(1, items.length - 1), y: p.t + ch - x.value / max * ch, ...x })); c.clearRect(0, 0, w, h); c.strokeStyle = '#dce6ef'; c.fillStyle = '#64748b'; c.font = '10px Segoe UI'; c.textAlign = 'right'; for (let i = 0; i < 5; i++) { const y = p.t + ch * i / 4, val = Math.round(max * (4 - i) / 4 * 10) / 10; c.beginPath(); c.moveTo(p.l, y); c.lineTo(w - p.r, y); c.stroke(); c.fillText(val, p.l - 8, y + 3) } c.save(); c.translate(12, p.t + ch / 2); c.rotate(-Math.PI / 2); c.textAlign = 'center'; c.fillText(yTitle, 0, 0); c.restore(); c.textAlign = 'center'; c.fillText(xTitle, p.l + cw / 2, h - 3); if (pts.length) { const g = c.createLinearGradient(0, p.t, 0, p.t + ch); g.addColorStop(0, 'rgba(8,120,209,.32)'); g.addColorStop(1, 'rgba(67,216,255,.03)'); c.beginPath(); c.moveTo(pts[0].x, p.t + ch); pts.forEach(q => c.lineTo(q.x, q.y)); c.lineTo(pts[pts.length - 1].x, p.t + ch); c.closePath(); c.fillStyle = g; c.fill(); c.beginPath(); pts.forEach((q, i) => i ? c.lineTo(q.x, q.y) : c.moveTo(q.x, q.y)); c.strokeStyle = '#0878d1'; c.lineWidth = 3; c.stroke() } c.fillStyle = '#64748b'; c.font = '9px Segoe UI'; const step = Math.max(1, Math.ceil(items.length / 6)); pts.forEach((q, i) => { if (i % step === 0 || i === pts.length - 1) c.fillText(q.label, q.x, h - 17) }); hoverChart(cv, pts, tid) }
        function hoverChart(cv, pts, tid) { cv.onmousemove = e => { if (!pts.length) return; const r = cv.getBoundingClientRect(), mx = e.clientX - r.left, my = e.clientY - r.top; let b = pts[0]; pts.forEach(p => { if (Math.hypot(p.x - mx, p.y - my) < Math.hypot(b.x - mx, b.y - my)) b = p }); const t = $(tid); t.textContent = b.label + ': ' + b.value; t.style.left = b.x + 'px'; t.style.top = b.y + 'px'; t.style.display = 'block' }; cv.onmouseleave = () => $(tid).style.display = 'none' }
        function drawUserTrend() { const items = dailyRange(profiles.filter(x => x.last).map(x => ({ last: x.last })), 30); $('trendTotal').textContent = items.reduce((s, x) => s + x.value, 0); $('trendPeak').textContent = Math.max(0, ...items.map(x => x.value)); $('trendAvg').textContent = Math.round(items.reduce((s, x) => s + x.value, 0) / 30 * 10) / 10; drawLine('trendChart', 'trendWrap', 'trendTip', items, 'Date', 'Users') }
        function drawDailyActivity() { const now = new Date(), elapsed = now.getHours() * 3600000 + now.getMinutes() * 60000 + now.getSeconds() * 1000 + now.getMilliseconds(), comparable = dailyComparisonActivities.filter(x => { const d = new Date(x.time); if (Number.isNaN(d.getTime())) return false; const eventElapsed = d.getHours() * 3600000 + d.getMinutes() * 60000 + d.getSeconds() * 1000 + d.getMilliseconds(); return eventElapsed <= elapsed }), items = dailyRange(comparable, 7), values = items.map(x => x.value), today = values[6] || 0, previous = values.slice(0, 6), average = previous.reduce((sum, value) => sum + value, 0) / 6, difference = today - average; $('dailyTodayTotal').textContent = today.toLocaleString(); $('dailyPreviousAverage').textContent = (Math.round(average * 10) / 10).toLocaleString(); $('dailyDifference').textContent = (difference > 0 ? '+' : '') + (Math.round(difference * 10) / 10).toLocaleString(); drawDailyComparison(items) }
        function drawDailyComparison(items) { const { cv, c, w, h } = canvasSetup('dailyChart', 'dailyWrap'), p = { l: 52, r: 18, t: 22, b: 58 }, cw = w - p.l - p.r, ch = h - p.t - p.b, max = Math.max(1, ...items.map(x => x.value)), slot = cw / Math.max(1, items.length), barWidth = Math.min(72, slot * .58), bars = []; c.clearRect(0, 0, w, h); c.font = '10px Segoe UI'; c.textAlign = 'right'; c.fillStyle = '#64748b'; c.strokeStyle = '#dce6ef'; for (let i = 0; i < 5; i++) { const y = p.t + ch * i / 4, val = Math.round(max * (4 - i) / 4); c.beginPath(); c.moveTo(p.l, y); c.lineTo(w - p.r, y); c.stroke(); c.fillText(val, p.l - 8, y + 3) } c.save(); c.translate(12, p.t + ch / 2); c.rotate(-Math.PI / 2); c.textAlign = 'center'; c.fillText('Activities', 0, 0); c.restore(); items.forEach((item, i) => { const x = p.l + slot * i + (slot - barWidth) / 2, bh = item.value / max * ch, visualHeight = Math.max(3, bh), y = p.t + ch - visualHeight, isToday = i === items.length - 1, g = c.createLinearGradient(0, y, 0, p.t + ch); if (isToday) { g.addColorStop(0, '#10b981'); g.addColorStop(1, '#047857') } else { g.addColorStop(0, '#43d8ff'); g.addColorStop(1, '#0878d1') } c.fillStyle = g; c.beginPath(); c.roundRect(x, y, barWidth, visualHeight, 8); c.fill(); c.textAlign = 'center'; c.fillStyle = isToday ? '#047857' : '#64748b'; c.font = isToday ? '700 10px Segoe UI' : '10px Segoe UI'; c.fillText(isToday ? 'Today' : item.label, x + barWidth / 2, h - 22); c.fillStyle = '#1d3550'; c.font = '700 11px Segoe UI'; c.fillText(String(item.value), x + barWidth / 2, Math.max(13, y - 7)); bars.push({ left: x, right: x + barWidth, top: y, bottom: p.t + ch, x: x + barWidth / 2, y: y, date: item.date, isToday, value: item.value }) }); c.fillStyle = '#64748b'; c.font = '10px Segoe UI'; c.textAlign = 'center'; c.fillText('Same elapsed time window across seven days', p.l + cw / 2, h - 3); hoverDailyComparison(cv, bars, 'dailyTip') }
        function hoverDailyComparison(cv, bars, tid) { cv.onmousemove = e => { const r = cv.getBoundingClientRect(), mx = e.clientX - r.left, my = e.clientY - r.top, bar = bars.find(b => mx >= b.left && mx <= b.right && my >= b.top && my <= b.bottom), tip = $(tid); if (!bar) { tip.style.display = 'none'; return } const dateLabel = bar.date.toLocaleDateString([], { weekday: 'long', month: 'short', day: 'numeric' }), activityLabel = bar.value === 1 ? 'activity' : 'activities'; tip.textContent = (bar.isToday ? 'Today, ' : '') + dateLabel + ': ' + bar.value + ' ' + activityLabel; tip.style.left = bar.x + 'px'; tip.style.top = Math.max(30, bar.top) + 'px'; tip.style.display = 'block' }; cv.onmouseleave = () => $(tid).style.display = 'none' }
        function openActivityLink(link) { const value = String(link || '').trim(); if (!value) return; const target = /^https?:\/\//i.test(value) ? value : root().replace(/\/$/, '') + '/' + value.replace(/^\//, ''); window.open(target, '_blank', 'noopener,noreferrer') }
        async function openRecord(id) { const a = xrm(); if (a?.Navigation?.openForm) return a.Navigation.openForm({ entityName: E, entityId: id, openInNewWindow: true }); open(root().replace(/\/$/, '') + '/main.aspx?pagetype=entityrecord&etn=' + E + '&id=' + encodeURIComponent(id), '_blank', 'noopener') }
        async function showLicenses(id, user) { $('modalUser').textContent = 'User: ' + user; $('modalContent').innerHTML = 'Loading...'; $('licenseModal').classList.add('open'); try { const a = await relatedLicenses(id); $('modalContent').innerHTML = a.length ? '<div class="license-list">' + a.map(x => `<article class="license-card"><h4>${esc(friendly(x.cc_name))}</h4><div class="sku">SKU ID: ${esc(x.cc_skuid || 'Not available')}</div></article>`).join('') + '</div>' : '<p>No licenses found.</p>' } catch (e) { $('modalContent').textContent = e.message } }
        async function refreshActivity() { const l = $('activityLoading'), e = $('activityError'); l.classList.add('show'); e.classList.remove('show'); try { await Promise.all([loadActivity(), loadDailyComparison()]); renderActivity() } catch (err) { activities = []; logins = []; renderActivity(); e.textContent = 'User Activity could not be loaded. ' + err.message; e.classList.add('show') } finally { l.classList.remove('show') } }
        async function loadBase() { const b = $('refresh'); b.disabled = true; b.classList.add('loading'); $('alert').classList.remove('show'); try { await Promise.all([loadProfiles(), loadLicenseAnalytics()]); renderUsers(); renderLicenses(); $('updated').textContent = 'Last refreshed: ' + new Date().toLocaleString(); if (activityLoaded) await refreshActivity() } catch (e) { $('alert').textContent = e.message; $('alert').classList.add('show') } finally { b.disabled = false; b.classList.remove('loading') } }
        document.addEventListener('click', e => { const tab = e.target.closest('[data-tab]'), o = e.target.closest('[data-open]'), l = e.target.closest('[data-license]'), ar = e.target.closest('[data-activity-link]'); if (tab) { document.querySelectorAll('.tab-btn').forEach(x => x.classList.toggle('active', x === tab)); document.querySelectorAll('.tab-page').forEach(x => x.classList.toggle('active', x.id === tab.dataset.tab)); setTimeout(() => { if (tab.dataset.tab === 'userPage') drawUserTrend(); if (tab.dataset.tab === 'licensePage') renderLicenses(); if (tab.dataset.tab === 'activityPage') { if (activityLoaded) renderActivity(); else refreshActivity() } }, 80) } if (o) openRecord(o.dataset.open); if (l) showLicenses(l.dataset.license, l.dataset.user); if (ar) openActivityLink(ar.dataset.activityLink) });
        function hideHelp() { const p = $('helpPopover'); p.classList.remove('show'); p.textContent = ''; p.dataset.owner = '' }
        function showHelp(button) { const p = $('helpPopover'), r = button.getBoundingClientRect(); p.textContent = button.dataset.help || ''; p.classList.add('show'); const pr = p.getBoundingClientRect(), gap = 9, left = Math.min(window.innerWidth - pr.width - 12, Math.max(12, r.left + r.width / 2 - pr.width / 2)); let top = r.bottom + gap; if (top + pr.height > window.innerHeight - 12) top = Math.max(12, r.top - pr.height - gap); p.style.left = left + 'px'; p.style.top = top + 'px'; p.dataset.owner = button.getAttribute('aria-label') || '' }
        document.addEventListener('click', e => { const b = e.target.closest('.help-button'); if (b) { e.preventDefault(); e.stopPropagation(); const p = $('helpPopover'); if (p.classList.contains('show') && p.dataset.owner === (b.getAttribute('aria-label') || '')) hideHelp(); else showHelp(b); return } if (!e.target.closest('#helpPopover')) hideHelp() });
        document.addEventListener('keydown', e => { if (e.key === 'Escape') hideHelp() });
        window.addEventListener('scroll', hideHelp, true);
        window.addEventListener('resize', hideHelp);
        $('activityPrev').onclick = () => { if (activityPage > 1) { activityPage--; renderActivityPage() } }; $('activityNext').onclick = () => { if (activityPage < Math.ceil(activities.length / PAGE_SIZE)) { activityPage++; renderActivityPage() } }; $('loginPrev').onclick = () => { if (loginPage > 1) { loginPage--; renderLoginPage() } }; $('loginNext').onclick = () => { if (loginPage < Math.ceil(latestLogins().length / PAGE_SIZE)) { loginPage++; renderLoginPage() } }; $('modalClose').onclick = () => $('licenseModal').classList.remove('open'); $('refresh').onclick = runSystemRefresh; $('userSearch').oninput = renderUserRows; $('userFilter').onchange = renderUserRows; $('licenseSearch').oninput = renderLicenseRows; $('licenseFilter').onchange = renderLicenseRows; $('activityPeriod').onchange = refreshActivity; window.addEventListener('resize', () => { clearTimeout(window._resize); window._resize = setTimeout(() => { if ($('userPage').classList.contains('active')) drawUserTrend(); if ($('licensePage').classList.contains('active')) renderLicenses(); if ($('activityPage').classList.contains('active') && activityLoaded) renderActivity() }, 140) }); loadBase();
    </script>


</body></html>