solverforge-ui 0.5.0

Frontend component library for SolverForge constraint-optimization applications
Documentation
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>solverforge-ui timeline demo</title>
  <link rel="stylesheet" href="../static/sf/vendor/fontawesome/css/fontawesome.min.css">
  <link rel="stylesheet" href="../static/sf/vendor/fontawesome/css/solid.min.css">
  <link rel="stylesheet" href="../static/sf/sf.css">
  <script src="../static/sf/sf.js"></script>
  <style>
    body { margin: 0; }
    .demo-wrap { max-width: 1440px; margin: 0 auto; padding: 24px; }
    .demo-stack { display: grid; gap: 16px; }
    .demo-card {
      background: white;
      border: 1px solid var(--sf-gray-200);
      border-radius: 16px;
      box-shadow: var(--sf-shadow-base);
      padding: 18px;
    }
    .demo-card h1,
    .demo-card h2 { margin: 0 0 10px; }
    .demo-card p {
      color: var(--sf-gray-600);
      line-height: 1.6;
      margin: 0;
      max-width: 84ch;
    }
  </style>
</head>
<body class="sf-app">
  <main class="sf-main">
    <div class="demo-wrap demo-stack" id="app"></div>
  </main>

  <script>
    function dayMinute(dayIndex, hour, minute) {
      return dayIndex * 1440 + hour * 60 + (minute || 0);
    }

    function buildAxis(dayCount) {
      var weekday = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
      var days = [];
      var ticks = [];

      for (var dayIndex = 0; dayIndex < dayCount; dayIndex += 1) {
        days.push({
          label: weekday[dayIndex % 7] + ' ' + (20 + dayIndex),
          startMinute: dayIndex * 1440,
          endMinute: (dayIndex + 1) * 1440,
          isWeekend: dayIndex % 7 === 5 || dayIndex % 7 === 6,
          subLabel: dayIndex % 7 === 0 ? 'New roster' : '',
        });
      }

      for (var minute = 0; minute < dayCount * 1440; minute += 360) {
        var hour = Math.floor((minute % 1440) / 60);
        ticks.push({ minute: minute, label: (hour < 10 ? '0' + hour : String(hour)) + ':00' });
      }

      return {
        startMinute: 0,
        endMinute: dayCount * 1440,
        days: days,
        ticks: ticks,
        initialViewport: {
          startMinute: 0,
          endMinute: Math.min(dayCount, 14) * 1440,
        },
      };
    }

    function buildLocationItems() {
      return [
        {
          id: 'east-monday',
          clusterId: 'east-monday',
          startMinute: dayMinute(0, 6),
          endMinute: dayMinute(0, 18),
          label: 'Monday intake surge',
          tone: 'blue',
          summary: {
            primaryLabel: 'Monday intake surge',
            secondaryLabel: 'ER intake · trauma hold · overflow beds',
            count: 24,
            openCount: 3,
            toneSegments: [
              { tone: 'blue', count: 15 },
              { tone: 'amber', count: 6 },
              { tone: 'rose', count: 3 },
            ],
          },
          detailItems: [
            { id: 'east-1', startMinute: dayMinute(0, 6), endMinute: dayMinute(0, 14), label: 'ER intake', meta: '8 clinicians', tone: 'blue' },
            { id: 'east-2', startMinute: dayMinute(0, 7), endMinute: dayMinute(0, 16), label: 'Trauma hold', meta: '6 clinicians', tone: 'amber' },
            { id: 'east-3', startMinute: dayMinute(0, 8), endMinute: dayMinute(0, 18), label: 'Overflow beds', meta: '10 clinicians', tone: 'rose' },
          ],
        },
        { id: 'east-4', startMinute: dayMinute(2, 6), endMinute: dayMinute(2, 18), label: 'Cardio block', meta: '5 clinicians', tone: 'emerald' },
        { id: 'east-5', startMinute: dayMinute(5, 10), endMinute: dayMinute(5, 20), label: 'Weekend surge', meta: '7 clinicians', tone: 'amber' },
      ];
    }

    function buildWestItems() {
      return [
        {
          id: 'west-tuesday',
          clusterId: 'west-tuesday',
          startMinute: dayMinute(1, 6),
          endMinute: dayMinute(1, 15),
          label: 'Tuesday reassignment wave',
          tone: 'violet',
          summary: {
            primaryLabel: 'Tuesday reassignment wave',
            secondaryLabel: 'Pediatrics triage · float pool',
            count: 9,
            openCount: 1,
            toneSegments: [
              { tone: 'violet', count: 6 },
              { tone: 'cyan', count: 2 },
              { tone: 'rose', count: 1 },
            ],
          },
          detailItems: [
            { id: 'west-1', startMinute: dayMinute(1, 6), endMinute: dayMinute(1, 13), label: 'Pediatrics triage', meta: '4 clinicians', tone: 'violet' },
            { id: 'west-2', startMinute: dayMinute(1, 8), endMinute: dayMinute(1, 15), label: 'Float pool', meta: '3 clinicians', tone: 'cyan' },
          ],
        },
        { id: 'west-3', startMinute: dayMinute(4, 7), endMinute: dayMinute(4, 19), label: 'Ward reset', meta: '2 clinicians', tone: 'cyan' },
        { id: 'west-4', startMinute: dayMinute(7, 6), endMinute: dayMinute(7, 18), label: 'Rotation handoff', meta: '5 clinicians', tone: 'blue' },
      ];
    }

    function buildEmployeeItems(baseId, dayOffset, tone) {
      return [
        { id: baseId + '-1', startMinute: dayMinute(dayOffset, 6), endMinute: dayMinute(dayOffset, 14), label: 'Primary shift', meta: 'Ward East', tone: tone },
        { id: baseId + '-2', startMinute: dayMinute(dayOffset, 11), endMinute: dayMinute(dayOffset, 17), label: 'Handoff overlap', meta: 'Safety round', tone: tone },
        { id: baseId + '-3', startMinute: dayMinute(dayOffset + 1, 7), endMinute: dayMinute(dayOffset + 1, 15), label: 'Primary shift', meta: 'Ward West', tone: tone },
        { id: baseId + '-4', startMinute: dayMinute(dayOffset + 1, 14), endMinute: dayMinute(dayOffset + 1, 20), label: 'Late coverage', meta: 'ER intake', tone: tone },
      ];
    }

    function buildModel() {
      return {
        axis: buildAxis(28),
        lanes: [
          {
            id: 'ward-east',
            label: 'By location · Ward East',
            mode: 'overview',
            badges: ['default', { label: 'overview', style: { bg: 'rgba(59,130,246,0.12)', color: '#1d4ed8', border: '1px solid rgba(59,130,246,0.22)' } }],
            stats: [
              { label: 'Coverage', value: '92%' },
              { label: 'Requests', value: 18 },
            ],
            overlays: [
              { dayIndex: 5, label: 'Unavailable', tone: 'red' },
              { dayIndex: 11, dayCount: 2, label: 'Desired weekend', tone: 'emerald' },
            ],
            items: buildLocationItems(),
          },
          {
            id: 'ward-west',
            label: 'By location · Ward West',
            mode: 'overview',
            badges: ['secondary', { label: 'overview', style: { bg: 'rgba(139,92,246,0.12)', color: '#6d28d9', border: '1px solid rgba(139,92,246,0.22)' } }],
            stats: [
              { label: 'Coverage', value: '88%' },
              { label: 'Requests', value: 12 },
            ],
            overlays: [
              { dayIndex: 6, label: 'Unavailable', tone: 'red' },
              { dayIndex: 15, dayCount: 3, label: 'Preferred span', tone: 'emerald' },
            ],
            items: buildWestItems(),
          },
          {
            id: 'employee-ada',
            label: 'By employee · Ada',
            mode: 'detailed',
            badges: ['detailed'],
            stats: [
              { label: 'Hours', value: '38h' },
              { label: 'PTO', value: '1 day' },
            ],
            overlays: [
              { dayIndex: 10, label: 'Unavailable', tone: 'red' },
              { dayIndex: 12, dayCount: 2, label: 'Desired', tone: 'emerald' },
            ],
            items: buildEmployeeItems('ada', 2, 'amber'),
          },
          {
            id: 'employee-marco',
            label: 'By employee · Marco',
            mode: 'detailed',
            badges: ['detailed'],
            stats: [
              { label: 'Hours', value: '42h' },
              { label: 'PTO', value: '0 days' },
            ],
            overlays: [
              { dayIndex: 9, dayCount: 2, label: 'Undesired', tone: 'rose' },
              { dayIndex: 18, label: 'Preferred', tone: 'emerald' },
            ],
            items: buildEmployeeItems('marco', 5, 'blue'),
          },
        ],
      };
    }

    var app = document.getElementById('app');

    var intro = document.createElement('section');
    intro.className = 'demo-card';
    intro.innerHTML = '<h1>Scheduling timeline</h1><p>This fixture uses <code>SF.rail.createTimeline()</code> as the canonical dense schedule surface. It mixes additive overview summaries, inline cluster expansion, detailed employee inspection, overlay bands, keyboard-focus tooltips, weekend shading, six-hour ticks, and drag-to-pan within a 28-day numeric horizon.</p>';
    app.appendChild(intro);

    var timelineCard = document.createElement('section');
    timelineCard.className = 'demo-card';
    timelineCard.innerHTML = '<h2>Hospital-like 28 day schedule</h2>';
    timelineCard.appendChild(SF.rail.createTimeline({
      title: 'Read-only schedule',
      subtitle: 'Overview by location, detailed by employee, drag to pan, focus or hover for detail.',
      label: 'Staffing lane',
      labelWidth: 280,
      model: buildModel(),
    }).el);
    app.appendChild(timelineCard);
  </script>
</body>
</html>