Osy#the first language built for agents
Agents firstAgentic appsWorkflowsDurable Execution — built inSecurityTestingThe editorThe UI modelOne program

Samples

wf-support-sla

wf-support-sla — the SUPPORT-TICKET workflow demo.

13 source files3 test files

The wf-support-sla sample, running.
Running, signed in, compiled from the source below.

Get it

// not one of the apps the toolchain ships: this one lives in the
// repository. Clone it, then:
$ cd demo/wf-support-sla
$ osy launch

The app

app.osy7 lines
// wf-support-sla — the SUPPORT-TICKET workflow demo.
app WfSupportSla {
  model "model/**/*.osy";
  tests "tests/**/*.test.osy";
  data  "data/**/*.json";
}
model/auth.osy51 lines
// Sign-in for the support desk.

// TWO ROLES, and they answer different questions. `Authenticator` is about the credential; `SupportManager` is
// AUTHORITY OVER WORK — who may move a commitment somebody else made.
[Role] enum AppRole { Authenticator, SupportManager }

entity RoleGrant {
  Agent Grantee;
  [Required] AppRole Level;
  security {
    allow read when IsAuthenticated;
    allow read when IsAuthenticator;
    allow create when IsAuthenticator;
  }
}

policy IsAuthenticator => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.Authenticator);
policy IsSupportManager => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.SupportManager);

[AuthMethod]
string Signup(string email, string password) {
  var local = Text.Split(email, "@")[0];
  var name = Text.Upper(Text.Substring(local, 0, 1)) + Text.Substring(local, 1, Text.Length(local) - 1);
  var a = new Agent { Name = name, Email = email, PasswordHash = Security.HashPassword(password),
                      Team = Team.Support, OnShift = true };

  if (!RoleGrant.Any(g => g.Level == AppRole.SupportManager)) {
    new RoleGrant { Grantee = a, Level = AppRole.SupportManager };
  }

  return Security.IssueJwt(a.Id, a.Email);
}

[AuthMethod]
string Login(string email, string password) {
  var a = Agent.Where(x => x.Email == email).FirstOrDefault();
  if (a == null) { Security.VerifyPassword(password); return ""; }
  if (a.PasswordHash == null) { return ""; }
  if (Security.VerifyPassword(password, a.PasswordHash)) { return Security.IssueJwt(a.Id, a.Email); }
  return "";
}

app.Auth = new PasswordAuth { LoginField = Email, PasswordField = PasswordHash };

app.AuthBootstrap = new AuthBootstrap {
  Role      = AppRole.Authenticator,
  Login     = Login,
  Signup    = Signup,
  LoginPage = LoginPage,   // where an unauthenticated visit to the board is sent
};
model/board.osy481 lines
// THE SLA BOARD — the screen the whole SLA layer exists for.

// A duration a human reads at a glance: "4h", "1h32m", "45m". Not a raw TimeSpan — "01:32:00" is a machine's format
// and this is a surface people scan.
string Duration(TimeSpan t) {
  var mins = Convert.ToInt(Math.Truncate(t.TotalMinutes));
  var neg  = mins < 0;
  if (neg) { mins = -mins; }
  var h = mins / 60;
  var m = mins % 60;
  var text = "";
  if (h > 0 && m > 0) { text = h + "h" + m + "m"; }
  else if (h > 0)     { text = h + "h"; }
  else if (m > 0)     { text = m + "m"; }
  else { text = Convert.ToInt(Math.Truncate(t.TotalSeconds)) + "s"; if (neg) { text = Convert.ToInt(-Math.Truncate(t.TotalSeconds)) + "s"; } }
  if (neg) { return "-" + text; }
  return text;
}

int SlaPercent(TimeSpan elapsed, TimeSpan budget) {
  if (budget.TotalSeconds <= 0.0) { return 0; }
  var pct = Convert.ToInt(Math.Floor(elapsed.TotalSeconds / budget.TotalSeconds * 100.0));
  if (pct > 100) { return 100; }
  if (pct < 0)   { return 0; }
  return pct;
}

string SlaText(TimeSpan elapsed, TimeSpan budget) => Duration(elapsed) + " of " + Duration(budget);

string Countdown(TimeSpan t) {
  var secs = Convert.ToInt(Math.Truncate(t.TotalSeconds));
  var neg = secs < 0;
  if (neg) { secs = -secs; }
  if (secs >= 3600) { return Duration(t); }
  var m = secs / 60;
  var s = secs % 60;
  var text = m + "m " + (s < 10 ? "0" + s : "" + s) + "s";
  if (neg) { return "-" + text; }
  return text;
}

string SlaRemainingText(TimeSpan breachesIn) {
  if (breachesIn.TotalSeconds < 0.0) { return "breached " + Countdown(breachesIn) + " ago"; }
  return "breaches in " + Countdown(breachesIn);
}

string SlaWord(TimeSpan breachesIn, TimeSpan budget) {
  if (breachesIn.TotalSeconds < 0.0) { return "breached"; }
  if (budget.TotalSeconds > 0.0 && breachesIn.TotalSeconds < budget.TotalSeconds * 0.25) { return "at risk"; }
  return "on time";
}

/// One ticket as the board reads it: the ticket's own facts, plus the promise that governs it right now. A `class`, so
/// it is an in-memory projection with no table behind it.
class DeskRow {
  public Guid TicketId;
  public string Subject;
  public string CustomerName;
  public Severity Severity;
  public TicketStatus Status;
  /// A ticket with no live clock (closed, cancelled, or parked with everything paused) is a legitimate row — it just
  /// has no countdown. Absence, not a zero that would draw as a full bar.
  public bool HasPromise;
  public string Promise;
  public TimeSpan Budget;
  public TimeSpan Elapsed;
  /// Only meaningful when `HasPromise` — it is set at the create site to `now` (a zero countdown) so the class has no
  /// invented default, and every reader gates on `HasPromise` rather than on a sentinel date.
  public DateTime BreachesAt;
  /// A promise that was MISSED stays missed, even if the ticket is then answered and closed well — so this is the
  /// RECORD, not the current state, and the two are deliberately different things on the row.
  public bool EverBreached;
  /// WHO HOLDS THE GOVERNING SLOT — read off the same `Workflow.Work` row the promise came from, never off a field on
  /// the ticket. Empty when nobody has picked it up, which is a real state and not a missing value.
  public bool HasAssignee;
  public string AssigneeName;
  public string AssigneeInitials;
  /// Is the signed-in agent holding it? The personal queue every desk has, answered on the server where the identity is.
  public bool IsMine;
  /// When it was raised. A board with no ages cannot tell a ticket that arrived a minute ago from one that has been
  /// sitting all week — and "how long has this been here" is the second question anyone asks after "is it late".
  public DateTime RaisedAt;
  /// THE URGENCY BAND, decided ONCE on the server and read by the ordering, the desk summary and the card alike.
  public Band Band;
  /// Kept for the card, which needs to know whether the number beside it is still moving.
  public bool BreachedNow;
  public bool AtRiskNow;
}

/// What the current promise IS, in this desk's words. The platform's own `SlaKind` labels are generic ("state
/// expiry"), and the same clock means different things here: a state `Expire` is the FIRST RESPONSE promise in `Open`
/// and the NEXT REPLY promise in `Working`. The app knows which; the platform cannot.
string PromiseName(TicketStatus s) {
  if (s == TicketStatus.Open)             { return "first response"; }
  if (s == TicketStatus.Working)          { return "next reply"; }
  if (s == TicketStatus.AwaitingCustomer) { return "customer reply"; }
  if (s == TicketStatus.AwaitingVendor)   { return "vendor reply"; }
  if (s == TicketStatus.Resolved)         { return "confirmation"; }
  return "";
}

/// THE DESK, on the server. One row per ticket, carrying the promise that governs across every slot open on its run —
/// which is what `Workflow.WorkByItem<Ticket>()` answers, so this function no longer folds it by hand. It stays a
/// server read for a real reason: `Elapsed` is ACCRUED, walked on the run's own ServiceHours calendar, and no client
/// can derive it. What is left here is the app's own half — the customer, the severity, the band, the roster names.
List<DeskRow> Desk() {
  var rows = new List<DeskRow>();
  var work = Workflow.WorkByItem<Ticket>().Include(r => r.Item).ToList();
  var roster = Agent.ToList();
  var me = Session.CurrentUser;

  foreach (var t in Ticket.Include(x => x.Customer).OrderBy(x => x.Subject)) {
    var row = new DeskRow {
      TicketId = t.Id, Subject = t.Subject, CustomerName = t.Customer.Name,
      Severity = t.Severity, Status = t.Status, HasPromise = false, Promise = PromiseName(t.Status),
      BreachesAt = DateTime.UtcNow,   // a zero countdown; every reader gates on HasPromise, never on this date
      EverBreached = t.ResponseBreached || t.ResolutionBreached || t.NextReplyBreached,
      HasAssignee = false, AssigneeName = "", AssigneeInitials = "",
      IsMine = false, RaisedAt = t.CreatedAt, BreachedNow = false, AtRiskNow = false, Band = Band.Idle,
    };
    foreach (var w in work) {
      if (w.Item.Id != t.Id) { continue; }
      if (w.BreachesAt != null) {
        row.HasPromise = true;
        row.Budget     = w.Budget ?? TimeSpan.Zero;
        row.Elapsed    = w.Elapsed ?? TimeSpan.Zero;
        row.BreachesAt = w.BreachesAt;
        var left = w.Remaining ?? TimeSpan.Zero;
        var budget = w.Budget ?? TimeSpan.Zero;
        row.BreachedNow = left.TotalSeconds < 0.0;
        row.AtRiskNow = !row.BreachedNow && budget.TotalSeconds > 0.0
                        && left.TotalSeconds < budget.TotalSeconds * 0.25;
      }
      if (w.Assignee != null) {
        foreach (var a in roster) {
          if (w.Assignee == a.Id) {
            row.HasAssignee = true; row.AssigneeName = a.Name; row.AssigneeInitials = Initials(a.Name);
            row.IsMine = me != null && a.Id == me.Id;
          }
        }
      }
    }
    var live = t.Status != TicketStatus.Closed && t.Status != TicketStatus.Cancelled;
    if (row.BreachedNow || (row.EverBreached && live)) { row.Band = Band.Breached; }
    else if (row.AtRiskNow) { row.Band = Band.AtRisk; }
    else if (row.HasPromise) { row.Band = Band.Ok; }
    else { row.Band = Band.Idle; }

    rows.Add(row);
  }

  return rows.OrderBy(r => r.Band == Band.Breached ? 0 : (r.Band == Band.AtRisk ? 1 : (r.Band == Band.Ok ? 2 : 3)))
             .ThenBy(r => r.BreachesAt)
             .ThenBy(r => r.RaisedAt)
             .ToList();
}

/// THE SAME DESK, as a PURE projection over values the page already holds — the live shape.
List<DeskRow> DeskRows(Ticket[] tickets, Osyrin.WorkItemRow_Ticket[] work, Agent[] roster, Agent? me) {
  var rows = new List<DeskRow>();
  foreach (var t in tickets) {
    var row = new DeskRow {
      TicketId = t.Id, Subject = t.Subject, CustomerName = t.Customer.Name,
      Severity = t.Severity, Status = t.Status, HasPromise = false, Promise = PromiseName(t.Status),
      BreachesAt = DateTime.UtcNow,
      EverBreached = t.ResponseBreached || t.ResolutionBreached || t.NextReplyBreached,
      HasAssignee = false, AssigneeName = "", AssigneeInitials = "",
      IsMine = false, RaisedAt = t.CreatedAt, BreachedNow = false, AtRiskNow = false, Band = Band.Idle,
    };
    foreach (var w in work) {
      if (w.Item.Id != t.Id) { continue; }
      if (w.BreachesAt != null) {
        row.HasPromise = true;
        row.Budget     = w.Budget ?? TimeSpan.Zero;
        row.Elapsed    = w.Elapsed ?? TimeSpan.Zero;
        row.BreachesAt = w.BreachesAt;
        var left = w.Remaining ?? TimeSpan.Zero;
        var budget = w.Budget ?? TimeSpan.Zero;
        row.BreachedNow = left.TotalSeconds < 0.0;
        row.AtRiskNow = !row.BreachedNow && budget.TotalSeconds > 0.0
                        && left.TotalSeconds < budget.TotalSeconds * 0.25;
      }
      if (w.Assignee != null) {
        foreach (var a in roster) {
          if (w.Assignee == a.Id) {
            row.HasAssignee = true; row.AssigneeName = a.Name; row.AssigneeInitials = Initials(a.Name);
            row.IsMine = me != null && a.Id == me.Id;
          }
        }
      }
    }
    var live = t.Status != TicketStatus.Closed && t.Status != TicketStatus.Cancelled;
    if (row.BreachedNow || (row.EverBreached && live)) { row.Band = Band.Breached; }
    else if (row.AtRiskNow) { row.Band = Band.AtRisk; }
    else if (row.HasPromise) { row.Band = Band.Ok; }
    else { row.Band = Band.Idle; }
    rows.Add(row);
  }
  return rows.OrderBy(r => r.Band == Band.Breached ? 0 : (r.Band == Band.AtRisk ? 1 : (r.Band == Band.Ok ? 2 : 3)))
             .ThenBy(r => r.BreachesAt)
             .ThenBy(r => r.RaisedAt)
             .ToList();
}

/// How long ago, in the coarsest unit that is still true — "3d", "4h", "12m". A board is scanned, and a timestamp
/// makes every reader do the subtraction themselves.
string Ago(TimeSpan since) {
  if (since.TotalDays >= 1.0)  { return Convert.ToInt(Math.Floor(since.TotalDays)) + "d"; }
  if (since.TotalHours >= 1.0) { return Convert.ToInt(Math.Floor(since.TotalHours)) + "h"; }
  if (since.TotalMinutes >= 1.0) { return Convert.ToInt(Math.Floor(since.TotalMinutes)) + "m"; }
  return "just now";
}

/// Does this row survive the board's filters? A pure function over the projection and the three controls, so the
/// filtering is one testable expression rather than three nested `if`s in a render body.
bool Matches(DeskRow r, string search, string queue, string severity) {
  if (queue == "mine" && !r.IsMine) { return false; }
  if (queue == "unassigned" && r.HasAssignee) { return false; }
  if (severity == "gold" && r.Severity != Severity.Gold) { return false; }
  if (severity == "basic" && r.Severity != Severity.Basic) { return false; }
  if (Text.IsEmpty(search)) { return true; }
  var q = Text.Lower(search);
  return Text.Contains(Text.Lower(r.Subject), q) || Text.Contains(Text.Lower(r.CustomerName), q);
}

/// The board's rows after its three controls. A plain client function over the projection — no entity reads, so it is
/// safe in a computed — and it keeps `Matches` a single testable expression rather than a predicate inlined per lane.
List<DeskRow> Filtered(DeskRow[] rows, string search, string queue, string severity) {
  var kept = new List<DeskRow>();
  foreach (var r in rows) { if (Matches(r, search, queue, severity)) { kept.Add(r); } }
  return kept;
}

[Page("/")]
[Layout(DeskShell)]
[Render(CSR)]
[Title("Support board")]
component SlaBoard() {
  live var tickets = Ticket.Include(x => x.Customer).OrderBy(x => x.Subject).ToList();
  live var work = Workflow.WorkByItem<Ticket>().Include(r => r.Item).ToList();
  live var roster = Agent.ToList();
  live var me = Session.CurrentUser;
  live var rows = DeskRows(tickets, work, roster, me);

  string search = "";
  string queue = "all";        // all · mine · unassigned
  string severity = "any";     // any · gold · basic

  live var visible = Filtered(rows, search, queue, severity);

  action ShowAll()        { queue = "all"; }
  action ShowMine()       { queue = "mine"; }
  action ShowUnassigned() { queue = "unassigned"; }
  action AnySeverity()    { severity = "any"; }
  action OnlyGold()       { severity = "gold"; }
  action OnlyBasic()      { severity = "basic"; }
  action ClearSearch()    { search = ""; }

  render {
    Stack(gap: 5) {
      PageHead("Open work", "Every ticket by where it is — and how each one is doing against its promise.");

      if (rows.Count() == 0) {
        Box(bg: Colors.Surface, rounded: Radius.Card, p: 5, borderW: 1, border: Colors.Border) {
          Stack(gap: 2, align: Align.Center) {
            Text("Nothing is open", fontSize: FontSize.Section, fontWeight: FontWeight.Medium, color: Colors.OnBg);
            Link(href: "/desk") { Text("Raise the first ticket from the desk", fontSize: FontSize.Body, color: Colors.Primary); }
          }
        }
      } else {
        Row(gap: 3, align: Align.Center, wrap: "wrap") {
          Row(gap: 1, align: Align.Center) {
            Chip("All", "Show all tickets", queue == "all", ShowAll);
            Chip("Mine", "Show only mine", queue == "mine", ShowMine);
            Chip("Unassigned", "Show only unassigned", queue == "unassigned", ShowUnassigned);
          }
          Row(gap: 1, align: Align.Center) {
            Chip("Any severity", "Any severity", severity == "any", AnySeverity);
            Chip("Gold", "Filter to Gold", severity == "gold", OnlyGold);
            Chip("Basic", "Filter to Basic", severity == "basic", OnlyBasic);
          }
          Row(gap: 2, align: Align.Center, grow: 1, minW: "0") {
            Input(value: search, label: "Search subject or customer", placeholder: "Search subject or customer", grow: 1);
            if (search != "") {
              Pressable(onClick: ClearSearch, label: "Clear search") {
                Text("Clear", fontSize: FontSize.Caption, color: Colors.TextMuted);
              }
            }
          }
        }

        Row(gap: 3, align: Align.Start, overflowX: "auto", pb: 3, role: UiRole.Region, label: "Board") {
          foreach (var s in TicketStatus.Members) {
            Lane(s, visible.Where(r => r.Status == s).ToList());
          }
        }
      }
    }
  }
}

/// Which of the desk's four buckets a tally counts. A `variants` block keyed on an enum parameter is the shape the
/// platform means, and it is the better one: the CALL SITE names the meaning and the component owns the palette, so
/// there is one place to change what "at risk" looks like.
enum Band { Breached, AtRisk, Ok, Idle }

/// One number and what it counts. Four of these are the whole desk-health read: a person checks them before reading a
/// single card, and a zero is as informative as a five.
[Composable] component Tally(int count, string label, Band band) {
  variants {
    base { Display = Display.InlineFlex; Px = "12px"; Py = "8px"; Rounded = Radius.Control; }
    band {
      Breached { Bg = Colors.TintBreached; Color = Colors.Breached; }
      AtRisk   { Bg = Colors.TintAtRisk;   Color = Colors.AtRisk; }
      Ok       { Bg = Colors.TintOk;       Color = Colors.Ok; }
      Idle     { Bg = Colors.Sunken;       Color = Colors.TextMuted; }
    }
  }
  render {
    Row(align: Align.Center, gap: 2) {
      Text(count + " " + label, fontSize: FontSize.Caption, fontWeight: FontWeight.Medium);
    }
  }
}

/// A filter chip. Selected is the BRAND tint, not a border — a board has three of these rows and outline-only
/// selection makes the reader hunt for which one is on.
[Composable] component Chip(string label, string name, bool on, Action onPick) {
  render {
    Pressable(onClick: onPick, label: name, selected: on) {
      Row(align: Align.Center, h: "30px", px: 3, rounded: Radius.Pill,
          bg: on ? Colors.TintPrimary : Colors.Sunken, color: on ? Colors.Primary : Colors.TextMuted,
          fontSize: FontSize.Caption, fontWeight: FontWeight.Medium) {
        Text(label);
      }
    }
  }
}

/// RAISE A TICKET. Chips rather than a dropdown for the two small choice sets: there are three customers and two
/// severities, and a select for three options is a click to open plus a click to choose where one would have done.
[Composable] component RaiseForm(Customer[] customers, Binding<string> subject, Guid picked, Severity severity,
                                 string problem, Action<Guid> onPickCustomer,
                                 Action onGold, Action onBasic, Action onRaise) {
  render {
    Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) {
      Stack(gap: 3) {
        Text("Raise a ticket", fontSize: FontSize.Section, fontWeight: FontWeight.Semibold, color: Colors.OnBg);
        Stack(gap: 1) {
          Text("What is wrong", fontSize: FontSize.Caption, color: Colors.TextMuted);
          TextArea(value: subject, label: "Ticket subject", rows: 3, fontSize: FontSize.Body,
                   placeholder: "Checkout returns 500 on card payment — started after the 14:00 deploy, card and Apple Pay both fail");
        }
        Row(gap: 3, align: Align.End, wrap: "wrap") {
          Stack(gap: 1) {
            Text("For whom", fontSize: FontSize.Caption, color: Colors.TextMuted);
            Row(gap: 1, align: Align.Center) {
              foreach (var c in customers) { Chip(c.Name, "For " + c.Name, c.Id == picked, () => onPickCustomer(c.Id)); }
            }
          }
          Stack(gap: 1) {
            Text("Promise", fontSize: FontSize.Caption, color: Colors.TextMuted);
            Row(gap: 1, align: Align.Center) {
              Chip("Gold", "Promise Gold", severity == Severity.Gold, onGold);
              Chip("Basic", "Promise Basic", severity == Severity.Basic, onBasic);
            }
          }
          Osyrin.Button("Raise ticket", onClick: onRaise, bg: Colors.Primary, color: Colors.OnPrimary, fontWeight: FontWeight.Medium);
        }
        if (problem != "") { Text(problem, fontSize: FontSize.Caption, color: Colors.Breached); }
      }
    }
  }
}

/// One lane: a state, its count, and its tickets. The lane is a SUNKEN panel and the cards on it are surfaces — which
/// is what lets a card read as a card without a shadow doing that work.
[Composable] component Lane(TicketStatus state, DeskRow[] tickets) {
  render {
    Stack(gap: 2, p: 2, w: Length.LaneW, shrink: 0, rounded: Radius.Card, bg: Colors.Sunken) {
      Row(gap: 2, align: Align.Center, px: 1, py: 1) {
        Text(state, fontSize: FontSize.Section, fontWeight: FontWeight.Semibold, color: Colors.OnBg);
        Text(tickets.Count(), fontSize: FontSize.Caption, color: Colors.TextMuted, px: 2, py: 1, bg: Colors.Surface, rounded: Radius.Pill);
      }
      if (tickets.Count() == 0) {
        Text("nothing here", fontSize: FontSize.Caption, color: Colors.TextMuted, px: 1, pb: 2);
      }
      Stack(gap: 2) {
        foreach (var t in tickets) { TicketCard(t); }
      }
    }
  }
}

/// One ticket, one promise.
[Composable] component TicketCard(DeskRow r) {
  live var breachesIn = r.BreachesAt - DateTime.UtcNow;

  render {
    Link(href: "/ticket/" + r.TicketId) {
      Stack(gap: 3, p: 3, rounded: Radius.Control, borderW: 1,
            bg: r.Band == Band.Breached ? Colors.TintBreached : Colors.Surface,
            border: r.Band == Band.Breached ? Colors.Breached : Colors.Border) {

        Stack(gap: 1) {
          Text(r.Subject, fontSize: FontSize.Body, fontWeight: FontWeight.Medium, color: Colors.OnBg);
          Text(r.CustomerName + " · raised " + Ago(DateTime.UtcNow - r.RaisedAt) + " ago",
               fontSize: FontSize.Caption, color: Colors.TextMuted);
        }

        Row(align: Align.Center, gap: 2) {
          SeverityChip(r.Severity);
          if (r.HasAssignee) { Holder(r.AssigneeName, r.AssigneeInitials); } else { Unheld(); }
          Spacer();
          if (r.EverBreached) {
            Text("missed a promise", fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.Breached);
          }
        }

        if (r.HasPromise) {
          Stack(gap: 1) {
            Box(h: "6px", w: "100%", rounded: Radius.Pill, bg: Colors.Sunken) {
              Box(h: "6px", rounded: Radius.Pill, w: SlaPercent(r.Elapsed, r.Budget) + "%",
                  bg: breachesIn.TotalSeconds < 0.0 ? Colors.Breached
                      : (breachesIn.TotalSeconds < r.Budget.TotalSeconds * 0.25 ? Colors.AtRisk : Colors.Ok));
            }
            Row(align: Align.Center, gap: 2) {
              Text(SlaWord(breachesIn, r.Budget), fontSize: FontSize.Caption, fontWeight: FontWeight.Medium,
                   color: breachesIn.TotalSeconds < 0.0 ? Colors.Breached
                          : (breachesIn.TotalSeconds < r.Budget.TotalSeconds * 0.25 ? Colors.AtRisk : Colors.Ok));
              Text("· " + SlaRemainingText(breachesIn), fontSize: FontSize.Caption, fontVariant: FontVariant.TabularNums,
                   color: breachesIn.TotalSeconds < 0.0 ? Colors.Breached : Colors.TextMuted);
            }
            Text(r.Promise + " · " + SlaText(r.Elapsed, r.Budget), fontSize: FontSize.Caption, color: Colors.TextMuted);
          }
        } else {
          Text(r.Status == TicketStatus.Closed || r.Status == TicketStatus.Cancelled ? "no promise left to keep"
               : r.EverBreached ? "no promise still running"
               : "promise paused",
               fontSize: FontSize.Caption, color: Colors.TextMuted);
        }
      }
    }
  }
}

[Composable] component EmptyDesk(Action onSeed) {
  render {
    Box(bg: Colors.Surface, rounded: Radius.Card, p: 5, borderW: 1, border: Colors.Border) {
      Stack(gap: 3, align: Align.Center) {
        Text("Nothing is waiting", fontSize: FontSize.Section, fontWeight: FontWeight.Medium, color: Colors.OnBg);
        Text("No ticket currently has a live promise. On a fresh install there is no desk to look at yet.",
             fontSize: FontSize.Body, color: Colors.TextMuted, textAlign: TextAlign.Center);
        Osyrin.Button("Create a sample desk", onClick: onSeed, bg: Colors.Primary, color: Colors.OnPrimary, fontWeight: FontWeight.Medium);
      }
    }
  }
}

/// One person on the hand-over list: who, whether they are at the desk, when they work, and what they are carrying.
/// ⚑ THE LOAD IS THE NUMBER THAT CHANGES A DECISION. "On shift" alone routes work to whoever is awake and already
/// drowning; the count beside it is what makes this a choice rather than a list.
[Composable] component PersonRow(Person p) {
  render {
    Row(align: Align.Center, gap: 3, px: 2, py: 2, rounded: Radius.Control, bg: p.IsMe ? Colors.TintPrimary : Colors.Surface) {
      Avatar(p.Initials, !p.OnShift);
      Stack(gap: 0, grow: 1, minW: "0") {
        Row(align: Align.Center, gap: 2) {
          Text(p.Name, fontSize: FontSize.Body, fontWeight: FontWeight.Medium, color: Colors.OnBg);
          if (p.IsMe) { Text("you", fontSize: FontSize.Caption, color: Colors.Primary, fontWeight: FontWeight.Medium); }
        }
        Text(p.Team + " · " + p.Hours, fontSize: FontSize.Caption, color: Colors.TextMuted);
      }
      Text(p.OnShift ? "on shift" : "off shift", fontSize: FontSize.Caption, fontWeight: FontWeight.Medium,
           color: p.OnShift ? Colors.Ok : Colors.TextMuted);
      Text(p.OpenTickets == 1 ? "1 open" : p.OpenTickets + " open",
           fontSize: FontSize.Caption, color: Colors.TextMuted, px: 2, py: 1, rounded: Radius.Pill, bg: Colors.Sunken);
    }
  }
}
model/people.osy103 lines
// WHO HOLDS THIS, AND WHO COULD.

/// Initials for an avatar — one letter for a single name, two for a full one. Nothing about a person's identity, just
/// a legible stand-in for a photograph this app has no pipeline for.
string Initials(string name) {
  if (Text.IsEmpty(name)) { return "?"; }
  var parts = Text.Split(name, " ");
  if (parts.Count() == 1) { return Text.Upper(Text.Substring(parts[0], 0, 1)); }
  return Text.Upper(Text.Substring(parts[0], 0, 1)) + Text.Upper(Text.Substring(parts[parts.Count() - 1], 0, 1));
}

/// "09:00" from a TimeSpan. A shift is read as a wall clock, not as a duration.
string Clock(TimeSpan t) {
  var mins = Convert.ToInt(Math.Truncate(t.TotalMinutes));
  var h = mins / 60;
  var m = mins % 60;
  return (h < 10 ? "0" + h : "" + h) + ":" + (m < 10 ? "0" + m : "" + m);
}

string TeamName(Team t) {
  if (t == Team.Support) { return "Support"; }
  return "Engineering";
}

/// One person as a hand-over screen needs them: who they are, whether they are AT the desk, when they work, and how
/// much they are already carrying. The last is the one that changes a decision — "Sue is on shift" and "Sue is on
/// shift holding nine tickets" are different answers to "who should take this".
class Person {
  public Guid AgentId;
  public string Name;
  public string Initials;
  public string Team;
  public bool OnShift;
  public string Hours;
  /// How many live promises this person is currently holding — counted off the same board read, so it cannot drift
  /// from what the lanes show.
  public int OpenTickets;
  /// Is this the signed-in agent? The one row that can act on itself today, since `Workflow.Claim` takes a slot FOR
  /// the caller and there is no verb for handing one to somebody else (see the ticket page).
  public bool IsMe;
}

/// THE ROSTER, with each person's current load. A server read: the load is counted from `Workflow.Work`, whose SLA
/// numbers are accrued on each run's own calendar and are not derivable anywhere else.
List<Person> Roster() {
  var work = Workflow.Work<Ticket>().ToList();
  var me = Session.CurrentUser;
  var people = new List<Person>();

  foreach (var a in Agent.OrderBy(x => x.Name)) {
    var held = 0;
    foreach (var w in work) { if (w.Assignee == a.Id) { held = held + 1; } }
    people.Add(new Person {
      AgentId = a.Id, Name = a.Name, Initials = Initials(a.Name), Team = TeamName(a.Team),
      OnShift = a.OnShift, OpenTickets = held, IsMe = me != null && a.Id == me.Id,
      Hours = Clock(a.ShiftStart) + "–" + Clock(a.ShiftEnd) + " " + a.ShiftZone,
    });
  }
  return people;
}

/// A round avatar. INITIALS, not an image: this app ships no upload path and no CDN, and a broken <img> is worse than
/// a letter. The moment there is a photo the shape does not change — one `Image` inside the same circle.
[Composable] component Avatar(string initials, bool dim) {
  render {
    Row(align: Align.Center, justify: Justify.Center, w: "28px", h: "28px", rounded: Radius.Pill,
        bg: dim ? Colors.Sunken : Colors.TintPrimary, decorative: true) {
      Text(initials, fontSize: FontSize.Caption, fontWeight: FontWeight.Semibold, color: dim ? Colors.TextMuted : Colors.Primary);
    }
  }
}

/// Who holds a thing, inline: the avatar and the name. `decorative` on the avatar means a reader hears the NAME once
/// rather than hearing two letters and then the name it stands for.
[Composable] component Holder(string name, string initials) {
  render {
    Row(align: Align.Center, gap: 2) {
      Avatar(initials, false);
      Text(name, fontSize: FontSize.Caption, color: Colors.TextMuted);
    }
  }
}

/// Nobody holds it. Said plainly rather than left blank — an empty space where an owner goes reads as a page that did
/// not finish loading, and "unassigned" is a real and actionable state on a support desk.
[Composable] component Unheld() {
  render {
    Row(align: Align.Center, gap: 2) {
      Avatar("–", true);
      Text("unassigned", fontSize: FontSize.Caption, color: Colors.TextMuted);
    }
  }
}

/// The name behind an actor id, or "the platform" when there is none. An engine-fired event (a reminder, a breach, a
/// deadline) genuinely has no actor, and naming the last human who touched the ticket would be the one thing a
/// timeline must never do.
string ActorName(Guid? actor, Person[] roster) {
  if (actor == null) { return "the platform"; }
  foreach (var p in roster) { if (p.AgentId == actor) { return p.Name; } }
  return "someone who has left";
}
model/seed.osy74 lines
// A sample desk, so the board has something to be a board OF on first run.

/// ⚑ IT COMMITS, AND THAT IS NOT DECORATION. Every `new` here would otherwise land in the CALLING PAGE's unit of
/// work, which nothing on this page ever commits: the board filled in optimistically and the rows were discarded
/// with no error the moment you reloaded. And a workflow run can only be started for a row that EXISTS, so the
/// create has to land before the settle below.
void SeedDemoDesk() {
  if (SlaTarget.Any()) { return; }   // already seeded — the board just has nothing open right now

  var aroundTheClock = new ServiceHours { Name = "AroundTheClock", Zone = "UTC" };
  var bizHours = new ServiceHours { Name = "BusinessHours", Zone = "Europe/Stockholm" };
  foreach (var d in [DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday]) {
    new ServiceWindow { ServiceHours = bizHours, Day = d, Start = TimeSpan.FromHours(9), End = TimeSpan.FromHours(17) };
  }

  new SlaTarget { Severity = Severity.Gold,  RespondWithin = TimeSpan.FromMinutes(30), NextReplyWithin = TimeSpan.FromHours(1),
                  ResolveWithin = TimeSpan.FromHours(2), ServiceHours = aroundTheClock };
  new SlaTarget { Severity = Severity.Basic, RespondWithin = TimeSpan.FromHours(4), NextReplyWithin = TimeSpan.FromHours(8),
                  ResolveWithin = TimeSpan.FromDays(5),  ServiceHours = bizHours };

  new Agent { Name = "Nina Alvarez", Team = Team.Support, OnShift = true,
              ShiftStart = TimeSpan.FromHours(8),  ShiftEnd = TimeSpan.FromHours(16), ShiftZone = "Europe/Stockholm" };
  new Agent { Name = "Sue Lindqvist", Team = Team.Support, OnShift = true,
              ShiftStart = TimeSpan.FromHours(14), ShiftEnd = TimeSpan.FromHours(22), ShiftZone = "Europe/Stockholm" };
  new Agent { Name = "Ravi Mehta",   Team = Team.Support, OnShift = false,
              ShiftStart = TimeSpan.FromHours(22), ShiftEnd = TimeSpan.FromHours(6),  ShiftZone = "Asia/Kolkata" };
  new Agent { Name = "Ed Barnes",    Team = Team.Engineering, OnShift = true,
              ShiftStart = TimeSpan.FromHours(9),  ShiftEnd = TimeSpan.FromHours(17), ShiftZone = "Europe/London" };

  var acme  = new Customer { Name = "Acme",       Email = "ops@acme.test" };
  var globex = new Customer { Name = "Globex",    Email = "help@globex.test" };
  var initech = new Customer { Name = "Initech",  Email = "support@initech.test" };

  new Ticket { Subject = "Checkout returns 500 on card payment", Customer = acme,    Severity = Severity.Gold };
  new Ticket { Subject = "Export job stuck since this morning",  Customer = globex,  Severity = Severity.Gold };
  new Ticket { Subject = "Invoice PDF has the wrong VAT line",   Customer = initech, Severity = Severity.Basic };
  new Ticket { Subject = "Add a second admin seat",              Customer = acme,    Severity = Severity.Basic };
  new Ticket { Subject = "SSO login loops after password reset", Customer = globex,  Severity = Severity.Basic };
  UnitOfWork.Commit();
}

/// A ticket with a DELIBERATELY SHORT promise, so a breach can be SEEN in real time — in a browser, by a person,
/// with no test clock. Everything else here needs `TestClock`, which is a test-surface verb; this is the same fact
/// reached through the ordinary engine, and it is the difference between photographing a breach and mocking one.
void SeedBreachingTicket(int seconds) {
  var gold = SlaTarget.Single(t => t.Severity == Severity.Gold);
  var respond = gold.RespondWithin;
  var resolve = gold.ResolveWithin;
  var reply   = gold.NextReplyWithin;

  gold.RespondWithin   = TimeSpan.FromSeconds(seconds);
  gold.NextReplyWithin = TimeSpan.FromSeconds(seconds);
  gold.ResolveWithin   = TimeSpan.FromSeconds(seconds);
  UnitOfWork.Commit();

  var acme = Customer.Single(c => c.Name == "Acme");
  new Ticket { Subject = "Payment webhooks stopped arriving", Customer = acme, Severity = Severity.Gold };
  UnitOfWork.Commit();

  gold.RespondWithin   = respond;
  gold.NextReplyWithin = reply;
  gold.ResolveWithin   = resolve;
  UnitOfWork.Commit();
}

/// RAISE A TICKET. A create + a commit, in that order and for that reason: a run can only be started for a row that
/// EXISTS, so the commit is what makes it exist — and the board can then answer "what does this owe" the moment the
/// card appears rather than whenever a background worker gets to it.
void RaiseTicket(string subject, Guid customerId, Severity severity) {
  var customer = Customer.Single(c => c.Id == customerId);
  new Ticket { Subject = subject, Customer = customer, Severity = severity };
  UnitOfWork.Commit();
}
model/service_hours_security.osy5 lines
// The SLA calendar's SECURITY, and it needs a file of its own.
partial entity ServiceHours {
  security { allow read when IsAuthenticated; allow create, update when IsAuthenticated; }
}
model/shell.osy99 lines
// THE SHELL — the chrome every signed-in page sits in, and the app's identity in one place.

[Layout]
component DeskShell() {
  action SignOut() { Session.SignOut(); }

  variants { base { Display = Display.Flex; MinH = "100vh"; Bg = Colors.Bg; Color = Colors.OnBg; } }

  render {
    Stack(gap: 0, grow: 1, minW: "0") {
      TopBar(Navigation.CurrentPath, SignOut);
      Row(justify: Justify.Center, align: Align.Start, grow: 1, minW: "0") {
        Stack(gap: 0, grow: 1, minW: "0", maxW: "1240px", w: "100%", p: 5) {
          Outlet();
        }
      }
    }
  }
}

/// The bar. Separated from the canvas by a hairline rather than a shadow — see the file header.
[Composable] component TopBar(string currentPath, Action onSignOut) {
  variants { base { Position = Position.Sticky; Top = "0"; Z = 30; Bg = Colors.Surface; BorderW = "0 0 1px 0"; Border = Colors.Border; } }
  render {
    Row(justify: Justify.Center, w: "100%") {
      Row(align: Align.Center, gap: 5, w: "100%", maxW: "1240px", px: 5, h: "60px") {
        Wordmark();
        Nav(currentPath);
        Spacer();
        Pressable(onClick: onSignOut, label: "Sign out") {
          Text("Sign out", fontSize: FontSize.Body, color: Colors.TextMuted);
        }
      }
    }
  }
}

/// THE ONE PLACE THE DISPLAY FACE IS USED. Fraunces at 900 is a variable display serif with real character, which is
/// exactly why it is confined to one word — a display face in a data table is why apps look amateur. The mark beside
/// it is the brand green; everything else on the screen earns its colour by being late.
[Composable] component Wordmark() {
  render {
    Row(gap: 2, align: Align.Center) {
      Row(align: Align.Center, justify: Justify.Center, w: "26px", h: "26px", rounded: Radius.Control, bg: Colors.Primary) {
        Text("W", fontFamily: Font.Serif, fontSize: FontSize.Body, fontWeight: FontWeight.Black, color: Colors.OnPrimary);
      }
      Text("Watchdesk", fontFamily: Font.Serif, fontSize: FontSize.Wordmark, fontWeight: FontWeight.Black,
           color: Colors.OnBg, letterSpacing: "-0.02em");
    }
  }
}

[Composable] component Nav(string currentPath) {
  render {
    Row(gap: 1, align: Align.Center) {
      NavItem("Board", "/", currentPath == "/");
      NavItem("The desk", "/desk", currentPath == "/desk");
      NavItem("On shift", "/on-shift", currentPath == "/on-shift");
    }
  }
}

/// One pill. Current = the brand tint; idle = muted text.
[Composable] component NavItem(string label, string href, bool current) {
  render {
    Link(href: href) {
      Row(align: Align.Center, h: "32px", px: 3, rounded: Radius.Pill,
          bg: current ? Colors.TintPrimary : "transparent",
          color: current ? Colors.Primary : Colors.TextMuted,
          fontSize: FontSize.Body, fontWeight: FontWeight.Medium) {
        Text(label);
      }
    }
  }
}

[Composable] component Spacer() { variants { base { Grow = 1; } } render { Box(); } }

/// A page heading block: the title, and a line of context under it.
[Composable] component PageHead(string title, string subtitle) {
  render {
    Stack(gap: 1) {
      Text(title, fontSize: FontSize.Title, fontWeight: FontWeight.Semibold, color: Colors.OnBg, letterSpacing: "-0.02em");
      if (subtitle != "") { Text(subtitle, fontSize: FontSize.Body, color: Colors.TextMuted); }
    }
  }
}

/// THE SEVERITY CHIP. Gold is a 24/7 promise and Basic a business-hours one, which is a fact about the CONTRACT rather
/// than about how a ticket is doing — so it is drawn in the brand tint and never in the SLA hues. Confusing "this
/// customer pays for 24/7" with "this ticket is late" is exactly the mistake the three-hue rule exists to prevent.
[Composable] component SeverityChip(Severity severity) {
  render {
    Row(align: Align.Center, h: "22px", px: 2, rounded: Radius.Pill, bg: Colors.TintPrimary) {
      Text(severity, fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.Primary);
    }
  }
}
model/support.osy265 lines
// SUPPORT TICKET — the HITL ticket flow + a resolve-gate + the contractual RESPONSE SLA + a clean follow-the-sun
// reassignment over an app `Available()` function. Faithful to workflow_design/support.osy.
enum Team         { Support, Engineering }
enum Severity     { Gold, Basic }                  // Gold = 24/7 promise; Basic = business-hours promise
/// WHERE A TICKET IS. The member NAME is what the code and the workflow compare against; the `[Label]` label is
/// what a person reads — and a board is read far more often than it is programmed against.
enum TicketStatus {
  Open,
  Working,
  [Label("Awaiting customer")] AwaitingCustomer,
  [Label("Awaiting vendor")]   AwaitingVendor,
  Resolved,
  Closed,
  Cancelled,
}

[Principal]
entity Agent {
  [Required, MaxLength(100)] string Name;
  Team Team = Team.Support;
  bool OnShift;                                    // availability — a roster stand-in the follow-the-sun reminder polls
  TimeSpan ShiftStart = TimeSpan.FromHours(9);
  TimeSpan ShiftEnd   = TimeSpan.FromHours(17);
  [MaxLength(60)] string ShiftZone = "Europe/Stockholm";
  [MaxLength(200), Unique] string? Email;
  [MaxLength(200)] string? PasswordHash;
  security {
    deny read PasswordHash when !IsAuthenticator;
    allow read, create, update when IsAuthenticator;
    allow read when IsAuthenticated;
    allow create, update when IsAuthenticated;
  }
}

entity Customer {
  [Required, MaxLength(200)] string Name;
  [Required, MaxLength(255)] string Email;
  security { allow read when IsAuthenticated; allow create, update when IsAuthenticated; }
}

enum MessageDirection { Outbound, Inbound }

/// ONE TURN OF THE CONVERSATION on a ticket.
entity Message {
  [Required] Ticket Ticket;
  Agent? Author;
  [Required, MaxLength(4000)] string Body;
  [Required] MessageDirection Direction = MessageDirection.Outbound;
  DateTime At;
  security { allow read when IsAuthenticated; allow create, update when IsAuthenticated; }
}

entity SlaTarget {
  Severity Severity = Severity.Gold;
  TimeSpan RespondWithin;
  /// Every SUBSEQUENT reply, not just the first — a real contract promises the conversation, not the opening line.
  TimeSpan NextReplyWithin;
  TimeSpan ResolveWithin;
  [Required] Osyrin.Workflow.ServiceHours ServiceHours;      // a baseline entity — referenced by its qualified name from an app
  security { allow read when IsAuthenticated; allow create, update when IsAuthenticated; }
}

entity Ticket {
  [Required, MaxLength(200)] string Subject;
  TicketStatus Status;
  Severity Severity = Severity.Gold;
  [Required] Customer Customer;
  [MaxLength(2000)] string? RootCause;              // gated by `Requires` before Resolve
  [MaxLength(2000)] string? Resolution;
  TimeSpan SlaRespondWithin;
  TimeSpan SlaNextReplyWithin;
  TimeSpan SlaResolveWithin;
  Osyrin.Workflow.ServiceHours ServiceHours;
  bool ResponseBreached;
  DateTime? ResponseBreachedAt;
  bool ResolutionBreached;
  DateTime? ResolutionBreachedAt;
  bool NextReplyBreached;
  DateTime? NextReplyBreachedAt;
  /// How many times the customer rejected a fix. A ticket that has come back three times is a different conversation.
  int ReopenCount;
  /// Chase messages sent while waiting on the customer — incremented by the `Chase` reminder.
  int ChasesSent;
  /// Who we are blocked on outside the company, while `AwaitingVendor`. Null the rest of the time.
  [MaxLength(120)] string? WaitingOnVendor;
  /// Chases sent to that vendor — incremented by the `ChaseVendor` reminder.
  int VendorChasesSent;
  /// Why the ticket was cancelled (duplicate, spam, withdrawn). Null unless it was.
  [MaxLength(500)] string? CancelReason;
  security { allow read when IsAuthenticated; allow create, update when IsAuthenticated; }
}

bool Available(Agent a) => a.OnShift;

Agent? FirstAvailable(Agent[] candidates) {
  foreach (var a in candidates) { if (Available(a)) return a; }
  return null;
}

workflow SupportTicket {
  Tracks       = Ticket.Status;
  Autostart    = true;
  Initial      = Open;
  ServiceHours = this.Item.ServiceHours;           // the SLA-accrual window this instance's clocks walk (W55)
  Accrues      = [Open, Working];                   // the clock runs only in these states (a customer-parked state pauses it)
  Deadline     = this.Item.SlaResolveWithin;        // the whole-instance RESOLUTION promise — pauses while parked

  event Respond(string reply);
  event Resolve(string summary);
  event AskCustomer();                              // park on the customer (stops the resolution clock)
  event CustomerReplied();                          // un-park (the clock resumes)
  event Close();
  event Reopen(string reason);                      // the fix didn't hold — back to Working
  event AskVendor(string vendor);                   // blocked on a third party (stops the resolution clock)
  event VendorReplied();                            // the vendor came back
  event Escalate(Severity severity);                // "this P3 is actually a P1" — re-snapshot the SLA + retarget
  [Authorize(u => u.Team == Team.Support)]
  event Cancel(string reason);                      // duplicate / spam / withdrawn — it should never have existed

  subscribe Escalate(Severity severity) as Bump {
    Candidates = u => u.Team == Team.Support;
  }

  on Deadline {
    this.Item.ResolutionBreached   = true;
    this.Item.ResolutionBreachedAt = DurableClock.UtcNow;
  }

  on Bump(Severity severity) {
    this.Item.Severity           = severity;
    var target = SlaTarget.Single(t => t.Severity == severity);
    this.Item.SlaRespondWithin   = target.RespondWithin;
    this.Item.SlaNextReplyWithin = target.NextReplyWithin;
    this.Item.SlaResolveWithin   = target.ResolveWithin;
    this.Item.ServiceHours       = target.ServiceHours;
    Workflow.Retarget();
  }

  on Cancel(string reason) {
    this.Item.CancelReason = reason;
    goto Cancelled;
  }

  Start {
    var target = SlaTarget.Single(t => t.Severity == this.Item.Severity);
    this.Item.SlaRespondWithin   = target.RespondWithin;
    this.Item.SlaNextReplyWithin = target.NextReplyWithin;
    this.Item.SlaResolveWithin   = target.ResolveWithin;
    this.Item.ServiceHours       = target.ServiceHours;
  }

  state Open {
    Expire = this.Item.SlaRespondWithin;
    on Expire {
      this.Item.ResponseBreached   = true;
      this.Item.ResponseBreachedAt = DurableClock.UtcNow;
    }

    subscribe Respond(string reply) as FirstReply {
      Candidates = u => u.Team == Team.Support;
      Reassign   = a => RoleGrant.Any(g => g.Grantee == a && g.Level == AppRole.SupportManager);

      Finished {
        Within = this.Item.SlaRespondWithin;
        Remind Nudge(After = Within / 2) {
          if (!Available(slot.Assignee)) {
            var cover = FirstAvailable(slot.Candidates);
            if (cover != null) slot.Assign(cover);
          }
        }
      }
    }
    on FirstReply(string reply) { goto Working; }
  }

  state Working {
    Expire = this.Item.SlaNextReplyWithin;
    on Expire {
      this.Item.NextReplyBreached   = true;
      this.Item.NextReplyBreachedAt = DurableClock.UtcNow;
    }

    subscribe Resolve(string summary) as Fix {
      Candidates = u => u.Team == Team.Support;

      Requires {
        RootCause { Must    = !Text.IsEmpty(this.Item.RootCause);   // recorded = a non-empty root cause (C#-faithful: an unset string reads null)
                    Message = "Record the root cause before resolving."; }
      }
    }
    subscribe AskCustomer() as Park {                        // park on the customer — pauses the resolution clock
      Candidates = u => u.Team == Team.Support;
    }
    subscribe AskVendor(string vendor) as ParkVendor {       // park on a THIRD PARTY — pauses it just the same
      Candidates = u => u.Team == Team.Support;
    }
    on Fix(string summary) {
      this.Item.Resolution = summary;
      goto Resolved;
    }
    on Park() { goto AwaitingCustomer; }
    on ParkVendor(string vendor) {
      this.Item.WaitingOnVendor = vendor;
      goto AwaitingVendor;
    }
  }

  state AwaitingVendor {
    subscribe VendorReplied() as VendorBack {
      Candidates = u => u.Team == Team.Support;
      Finished {
        Within  = TimeSpan.FromDays(10);
        Accrues = false;
        Remind ChaseVendor(After = TimeSpan.FromDays(2), ThenEvery = TimeSpan.FromDays(2), Accrues = false) {
          this.Item.VendorChasesSent = this.Item.VendorChasesSent + 1;
        }
      }
    }
    on VendorBack() {
      this.Item.WaitingOnVendor = null;
      goto Working;
    }
  }

  state AwaitingCustomer {
    subscribe CustomerReplied() as Back {
      Candidates = u => u.Team == Team.Support;     // an agent records the reply; a real portal would let the customer

      Finished {
        Within  = TimeSpan.FromDays(14);
        Accrues = false;
        Remind Chase(After = TimeSpan.FromDays(3), ThenEvery = TimeSpan.FromDays(4), Accrues = false) {
          this.Item.ChasesSent = this.Item.ChasesSent + 1;
        }
        Unfinished { goto Closed; }                 // fourteen days of silence is an answer
      }
    }
    on Back() { goto Working; }
  }

  state Resolved {
    subscribe Close() as Confirm {
      Candidates = u => u.Team == Team.Support;

      Finished {
        Within  = TimeSpan.FromDays(7);
        Accrues = false;
        Unfinished { goto Closed; }
      }
    }
    subscribe Reopen(string reason) as Reopened {
      Candidates = u => u.Team == Team.Support;
    }
    on Confirm { goto Closed; }
    on Reopened(string reason) {
      this.Item.ReopenCount = this.Item.ReopenCount + 1;
      this.Item.Resolution  = null;                 // the previous summary is no longer the answer
      goto Working;                                 // → back inside `Accrues`: the resolution clock RESUMES
    }
  }

  terminal success Closed    { }
  terminal cancel  Cancelled { Message = "ticket cancelled"; }
}
model/theme.osy47 lines
// wf-support-sla — the design tokens.

theme SupportDesk {

  Colors {
    Bg      = Modes.Of(light: "#F7FAF8", dark: "#0B1512");   // the page
    Surface = Modes.Of(light: "#FFFFFF", dark: "#122019");   // cards, the board's lanes
    Sunken  = Modes.Of(light: "#EDF3EF", dark: "#0E1A15");

    OnBg      = Modes.Of(light: "#0B1F17", dark: "#E8F2EC");   // body text
    TextMuted = Modes.Of(light: "#5A6B62", dark: "#93A79C");   // secondary text, timestamps
    Border    = Modes.Of(light: "#DDE7E1", dark: "#22322B");   // hairlines

    Muted = Modes.Of(light: "#EAF0EC", dark: "#1B2A23");

    Primary      = Modes.Of(light: Palette.From("#0A7D50"), dark: Palette.From("#43CE92"));   // the wordmark, primary actions, the active lane
    PrimaryHover = Modes.Of(light: "#086340", dark: "#63DCA8");   // pressed/hover
    OnPrimary    = Modes.Of(light: "#FFFFFF", dark: "#04120C");

    Ok       = Modes.Of(light: "#0E6E4E", dark: "#3FBF88");   // within the promise
    AtRisk   = Modes.Of(light: "#B7791F", dark: "#E0A93F");   // under a quarter of it left
    Breached = Modes.Of(light: "#B42318", dark: "#F27166");   // over

    TintOk       = Modes.Of(light: "#E4F1EA", dark: "#102E22");
    TintAtRisk   = Modes.Of(light: "#FBF0DC", dark: "#33260D");
    TintBreached = Modes.Of(light: "#FCE9E7", dark: "#3A1512");
    TintPrimary  = Modes.Of(light: "#DFF3E8", dark: "#0F3324");
  }

  Radius { Control = "8px"; Card = "12px"; Pill = "999px"; }
  Space  { Xs = "4px"; Sm = "8px"; Md = "14px"; Lg = "20px"; Xl = "32px"; }

  Font {
    // `Serif`, not `Display`: six demos spell a font SIZE `FontSize.Display`, and token leaf
    // names are one flat namespace — so no shared theme could dress both spellings.
    Serif = "Fraunces, Iowan Old Style, Palatino, Georgia, ui-serif, serif";
    Sans    = "ui-sans-serif, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif";
  }

  FontSize   { Caption = "12px"; Body = "14px"; Section = "16px"; Title = "22px"; Wordmark = "24px"; }
  FontWeight { Regular = 400; Medium = 500; Semibold = 600; Black = 900; }

  Length { LaneW = "290px"; }

  Breakpoints { Cozy = 900; }
}
model/pages/desk.osy68 lines
// THE DESK, AT A GLANCE — how the whole queue is doing, and the one form that adds to it.

[Page("/desk")]
[Layout(DeskShell)]
[Render(CSR)]
[Title("The desk")]
component DeskStatus() {
  DeskRow[] rows;
  on mount { rows = Desk(); }

  action Seed() { SeedDemoDesk(); rows = Desk(); }

  action SeedBreaching() { SeedBreachingTicket(45); rows = Desk(); }

  live var customers = Customer.OrderBy(c => c.Name).ToList();
  string newSubject = "";
  Guid newCustomer = Guid.Empty;   // "nobody chosen yet", said explicitly rather than left to a default
  Severity newSeverity = Severity.Gold;
  string raiseProblem = "";

  action PickCustomer(Guid id) { newCustomer = id; }
  action PickGold()  { newSeverity = Severity.Gold; }
  action PickBasic() { newSeverity = Severity.Basic; }

  action Raise() {
    raiseProblem = "";
    if (Text.IsEmpty(newSubject)) { raiseProblem = "A ticket needs a subject."; return; }
    if (newCustomer == Guid.Empty) { raiseProblem = "Choose which customer it is for."; return; }
    RaiseTicket(newSubject, newCustomer, newSeverity);
    newSubject = "";
    rows = Desk();
  }

  render {
    Stack(gap: 5) {
      Row(align: Align.Center, gap: 4) {
        PageHead("The desk", "How the queue is doing right now — and the one form that adds to it.");
        Spacer();
        Stack(gap: 2, align: Align.End) {
          if (rows.Count() > 0) {
            Text(rows.Count(rw => rw.EverBreached)
                 + (rows.Count(rw => rw.EverBreached) == 1 ? " missed promise on record" : " missed promises on record"),
                 fontSize: FontSize.Caption, fontWeight: FontWeight.Medium,
                 color: rows.Any(rw => rw.EverBreached) ? Colors.Breached : Colors.TextMuted);
            Pressable(onClick: SeedBreaching, label: "Raise a 45-second ticket") {
              Text("Raise a 45-second ticket", fontSize: FontSize.Caption, color: Colors.TextMuted);
            }
          }
        }
      }

      if (rows.Count() == 0) {
        EmptyDesk(Seed);
      } else {
        Row(gap: 2, align: Align.Center) {
          Tally(rows.Count(r => r.Band == Band.Breached), "breached", Band.Breached);
          Tally(rows.Count(r => r.Band == Band.AtRisk), "at risk", Band.AtRisk);
          Tally(rows.Count(r => r.Band == Band.Ok), "on time", Band.Ok);
          Tally(rows.Count(r => r.Band == Band.Idle), "no live promise", Band.Idle);
        }

        RaiseForm(customers, newSubject, newCustomer, newSeverity, raiseProblem,
                  PickCustomer, PickGold, PickBasic, Raise);
      }
    }
  }
}
model/pages/login.osy63 lines
// Sign-in, prefilled. This demo's subject is the SLA board, not the credential flow, so the fields carry working
// values and signing in is one click. "Create this account" doubles as first-run setup.
[Page("/login")]
[AllowAnonymous]
[Render(CSR)]
[Title("Support desk — sign in")]
component LoginPage() {
  string email = "sam@support.test";
  string password = "demo1234";
  string problem = "";

  action SignIn() {
    var ticket = Login(email, password);
    if (ticket == "") { problem = "That email and password don't match an agent."; }
    else { Session.SignIn(ticket); }
  }

  action Register() {
    var ticket = Signup(email, password);
    if (ticket == "") { problem = "Couldn't create that account."; }
    else { Session.SignIn(ticket); }
  }

  render {
    Row(align: Align.Center, justify: Justify.Center, minH: "100vh", p: 4, bg: Colors.Bg, color: Colors.OnBg) {
      Stack(gap: 4, w: "100%", maxW: "380px") {

        Stack(gap: 2) {
          Wordmark();
          Text("Every open ticket, and how it is doing against its promise.",
               fontSize: FontSize.Body, color: Colors.TextMuted);
        }

        Box(bg: Colors.Surface, rounded: Radius.Card, p: 5, borderW: 1, border: Colors.Border,
            shadow: "0 1px 2px rgba(11,31,23,0.04), 0 8px 24px rgba(11,31,23,0.06)") {
          Stack(gap: 3) {
            Stack(gap: 1) {
              Text("Email", fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.TextMuted);
              Input(value: email, label: "Email", placeholder: "you@support.test");
            }
            Stack(gap: 1) {
              Text("Password", fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.TextMuted);
              Input(value: password, type: "password", label: "Password", placeholder: "password");
            }

            if (problem != "") {
              Box(bg: Colors.Sunken, rounded: Radius.Control, px: 3, py: 2) {
                Text(problem, fontSize: FontSize.Caption, color: Colors.Breached);
              }
            }

            Osyrin.Button("Sign in", onClick: SignIn, bg: Colors.Primary, color: Colors.OnPrimary, fontWeight: FontWeight.Medium);
            Osyrin.Button("Create this account", onClick: Register);
          }
        }

        Text("Prefilled for the demo — sign in, or create the account on first run.",
             fontSize: FontSize.Caption, color: Colors.TextMuted, textAlign: TextAlign.Center);
      }
    }
  }
}
model/pages/on_shift.osy27 lines
// WHO IS ON THE DESK — its own screen, because it is its own question.

[Page("/on-shift")]
[Layout(DeskShell)]
[Render(CSR)]
[Title("On shift")]
component OnShift() {
  Person[] roster;
  on mount { roster = Roster(); }

  render {
    Stack(gap: 5) {
      PageHead("On shift", "Who is at the desk, when they work, and what they are carrying.");
      Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) {
        Stack(gap: 3, role: UiRole.Region, label: "Who is on the desk") {
          if (roster.Count() == 0) {
            Text("Nobody is on the roster yet.", fontSize: FontSize.Caption, color: Colors.TextMuted);
          }
          Stack(gap: 1) {
            foreach (var p in roster) { PersonRow(p); }
          }
        }
      }
    }
  }
}
model/pages/ticket.osy642 lines
// ONE TICKET — everything a person needs to move it on, without leaving the page.

[Page("/ticket/{id}")]
[Layout(DeskShell)]
[Render(CSR)]
[Title("Ticket")]
component TicketPage(Guid id) {
  live var tickets = Ticket.Include(t => t.Customer).Where(t => t.Id == id).ToList();

  live var moves = SupportTicket.For(Ticket.Where(t => t.Id == id).Single()).Transitions;
  live var checklist = SupportTicket.For(Ticket.Where(t => t.Id == id).Single()).Requirements;

  Osyrin.Workflow.WorkflowAuditEntry[] history;
  ThreadRow[] conversation;

  DeskRow[] rows;
  on mount { rows = Desk(); roster = Roster(); history = HistoryOf(id); conversation = ConversationOf(id); }

  Person[] roster;

  string tab = "do";
  action ShowDo()   { tab = "do"; }
  action ShowTalk() { tab = "talk"; }
  action ShowLog()  { tab = "log"; }
  action ShowGive() { tab = "give"; }

  string reply = "";
  string rootCause = "";
  string summary = "";
  string vendor = "";
  string reason = "";

  /// Where raising `ev` puts this ticket, as the state's own label. Empty when the engine's list does not carry the
  /// event at all — which is a real case, not a defect: see `Cancel` below.
  string DestOf(string ev) {
    var t = tickets.FirstOrDefault();
    foreach (var m in moves) {
      if (m.Event == ev) {
        if (t != null && m.TargetLabel == t.Status.Label) { return "Stays here"; }
        return m.TargetLabel;
      }
    }
    return "";
  }

  /// Why `ev` is shut, in the engine's own words — empty when it is open.
  string BlockedOf(string ev) {
    foreach (var m in moves) {
      if (m.Event == ev && !m.Allowed) { return m.Reason; }
    }
    return "";
  }

  action SendReply() {
    var t = tickets.FirstOrDefault();
    if (t == null || reply == "") { return; }
    DoRespond(t, reply);
    reply = "";
    rows = Desk(); history = HistoryOf(id); conversation = ConversationOf(id);
  }

  action SaveRootCause() {
    var t = tickets.FirstOrDefault();
    if (t == null) { return; }
    t.RootCause = rootCause;
    UnitOfWork.Commit();
    rows = Desk(); history = HistoryOf(id); conversation = ConversationOf(id);
  }

  action Resolve() {
    var t = tickets.FirstOrDefault();
    if (t == null || summary == "") { return; }
    DoResolve(t, summary);
    summary = "";
    rows = Desk(); history = HistoryOf(id); conversation = ConversationOf(id);
  }

  action Take() {
    DoClaimGoverning(id);
    rows = Desk(); roster = Roster(); history = HistoryOf(id); conversation = ConversationOf(id);
  }
  action Give(Guid agentId) {
    DoAssign(id, agentId);
    rows = Desk(); roster = Roster(); history = HistoryOf(id); conversation = ConversationOf(id);
  }

  action HandBack() {
    DoReleaseMine(id);
    rows = Desk(); roster = Roster(); history = HistoryOf(id); conversation = ConversationOf(id);
  }

  action AskCustomer()     { var t = tickets.FirstOrDefault(); if (t != null) { DoAskCustomer(t); rows = Desk(); roster = Roster(); history = HistoryOf(id); conversation = ConversationOf(id); } }
  action CustomerReplied() { var t = tickets.FirstOrDefault(); if (t != null) { DoCustomerReplied(t); rows = Desk(); } }
  action VendorReplied()   { var t = tickets.FirstOrDefault(); if (t != null) { DoVendorReplied(t); rows = Desk(); } }
  action Close()           { var t = tickets.FirstOrDefault(); if (t != null) { DoClose(t); rows = Desk(); } }

  action AskVendor() {
    var t = tickets.FirstOrDefault();
    if (t == null || vendor == "") { return; }
    DoAskVendor(t, vendor);
    vendor = "";
    rows = Desk(); history = HistoryOf(id); conversation = ConversationOf(id);
  }

  action Reopen() {
    var t = tickets.FirstOrDefault();
    if (t == null || reason == "") { return; }
    DoReopen(t, reason);
    reason = "";
    rows = Desk(); history = HistoryOf(id); conversation = ConversationOf(id);
  }

  action Cancel() {
    var t = tickets.FirstOrDefault();
    if (t == null || reason == "") { return; }
    DoCancel(t, reason);
    reason = "";
    rows = Desk(); history = HistoryOf(id); conversation = ConversationOf(id);
  }

  action Escalate() {
    var t = tickets.FirstOrDefault();
    if (t == null) { return; }
    DoEscalate(t, t.Severity == Severity.Gold ? Severity.Basic : Severity.Gold);
    rows = Desk(); history = HistoryOf(id); conversation = ConversationOf(id);
  }

  render {
    Stack(gap: 5) {
      foreach (var t in tickets) {

        Stack(gap: 4) {
          Row(align: Align.Center, gap: 3) {
            Stack(gap: 1) {
              Row(align: Align.Center, gap: 2) {
                Link(href: "/") { Text("Board", fontSize: FontSize.Caption, color: Colors.Primary, fontWeight: FontWeight.Medium); }
                Text("/", fontSize: FontSize.Caption, color: Colors.TextMuted);
                Text(t.Status, fontSize: FontSize.Caption, color: Colors.TextMuted);
              }
              Text(t.Subject, fontSize: FontSize.Title, fontWeight: FontWeight.Semibold, color: Colors.OnBg, letterSpacing: "-0.02em");
              Text(t.Customer.Name + " · " + t.Customer.Email, fontSize: FontSize.Body, color: Colors.TextMuted);
            }
            Spacer();
            SeverityChip(t.Severity);
          }

          Stack(gap: 4) {
            foreach (var r in rows.Where(x => x.TicketId == id).Take(1)) { PromisePanel(r); }
            HandoverPanel(rows.Where(x => x.TicketId == id).Take(1).ToList(), Take, HandBack);
          }

          if (t.ResponseBreached || t.NextReplyBreached || t.ResolutionBreached) {
            Box(bg: Colors.TintBreached, rounded: Radius.Card, p: 4) {
              Stack(gap: 2) {
                Text("Missed promises", fontSize: FontSize.Section, fontWeight: FontWeight.Semibold, color: Colors.Breached);
                if (t.ResponseBreached) {
                  Text("First response · " + t.ResponseBreachedAt.ToString("g"), fontSize: FontSize.Caption, color: Colors.Breached);
                }
                if (t.NextReplyBreached) {
                  Text("A reply · " + t.NextReplyBreachedAt.ToString("g"), fontSize: FontSize.Caption, color: Colors.Breached);
                }
                if (t.ResolutionBreached) {
                  Text("Resolution · " + t.ResolutionBreachedAt.ToString("g"), fontSize: FontSize.Caption, color: Colors.Breached);
                }
                Text("Recorded, not fatal — the customer still has their problem, so the ticket carries on.",
                     fontSize: FontSize.Caption, color: Colors.TextMuted);
              }
            }
          }

          Row(gap: 4, align: Align.End) {
            Tab("What you can do", "Show what you can do", tab == "do", ShowDo);
            Tab("Conversation", "Show the conversation", tab == "talk", ShowTalk);
            Tab("History", "Show the history", tab == "log", ShowLog);
            Stack(canSee: IsSupportManager) {
              Tab("Reassign", "Show reassign", tab == "give", ShowGive);
            }
          }

          if (tab == "talk") {
            Conversation(conversation);
          }

          if (tab == "give") {
            Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) {
              Stack(gap: 3, role: UiRole.Region, label: "Reassign") {
                Text("Hand it to someone", fontSize: FontSize.Section, fontWeight: FontWeight.Semibold, color: Colors.OnBg);
                Text("The reply promise RESTARTS for whoever receives it — a budget somebody had no chance to meet "
                     + "is a hot potato, not a commitment. They must be eligible to hold it, exactly as if they had "
                     + "taken it themselves.",
                     fontSize: FontSize.Caption, color: Colors.TextMuted);
                Stack(gap: 1) {
                  foreach (var p in roster) {
                    Row(align: Align.Center, gap: 3, px: 2, py: 2, rounded: Radius.Control, bg: Colors.Surface) {
                      Avatar(p.Initials, !p.OnShift);
                      Stack(gap: 0, grow: 1, minW: "0") {
                        Text(p.Name, fontSize: FontSize.Body, fontWeight: FontWeight.Medium, color: Colors.OnBg);
                        Text(p.Team + " · " + (p.OnShift ? "on shift" : "off shift")
                             + " · " + (p.OpenTickets == 1 ? "1 open" : p.OpenTickets + " open"),
                             fontSize: FontSize.Caption, color: Colors.TextMuted);
                      }
                      Osyrin.Button("Give it to " + p.Name, onClick: () => Give(p.AgentId));
                    }
                  }
                }
              }
            }
          }

          if (tab == "log") {
            Timeline(history, roster);
          }

          if (tab == "do") {
            Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) {
              Stack(gap: 4) {
                Text("What you can do", fontSize: FontSize.Section, fontWeight: FontWeight.Semibold, color: Colors.OnBg);

                if (moves.Any(m => m.Event == "Respond")) {
                  Verb("Reply to the customer", "The first reply is what the response promise is measured against.",
                       DestOf("Respond"), BlockedOf("Respond")) {
                    Row(gap: 2, align: Align.Start) {
                      TextArea(value: reply, label: "Your reply", rows: 4, grow: 1,
                               placeholder: "Looking into it now — I can see the failed charges on our side too.");
                      Osyrin.Button("Send reply", onClick: SendReply, bg: Colors.Primary, color: Colors.OnPrimary, fontWeight: FontWeight.Medium);
                    }
                  }
                }

                if (moves.Any(m => m.Event == "Resolve")) {
                  Verb("Resolve", "A root cause has to be on the record before a fix can be filed.",
                       DestOf("Resolve"), BlockedOf("Resolve")) {
                    Stack(gap: 3) {
                      foreach (var c in checklist) {
                        Row(gap: 2, align: Align.Center) {
                          Text(c.Met ? "done" : "needed", fontSize: FontSize.Caption, fontWeight: FontWeight.Medium,
                               px: 2, py: 1, rounded: Radius.Pill,
                               bg: c.Met ? Colors.TintOk : Colors.TintAtRisk, color: c.Met ? Colors.Ok : Colors.AtRisk);
                          Text(c.Message, fontSize: FontSize.Caption, color: Colors.TextMuted);
                        }
                      }
                      Row(gap: 2, align: Align.Center) {
                        TextArea(value: rootCause, label: "Root cause", rows: 3, placeholder: "Expired certificate");
                        Osyrin.Button("Record root cause", onClick: SaveRootCause);
                      }
                      Row(gap: 2, align: Align.Center) {
                        TextArea(value: summary, label: "What you did", rows: 3, placeholder: "Renewed the cert");
                        Osyrin.Button("Resolve ticket", onClick: Resolve,
                               disabled: !moves.Any(m => m.Event == "Resolve" && m.Allowed)
                                         || checklist.Any(c => !c.Met),
                               whenDenied: checklist.Any(c => !c.Met)
                                             ? checklist.Where(c => !c.Met).First().Message
                                             : "not available yet");
                      }
                    }
                  }
                }

                if (moves.Any(m => m.Event == "AskCustomer")) {
                  Verb("Wait on the customer", "Parking stops the resolution clock — the delay is not the desk's.",
                       DestOf("AskCustomer"), BlockedOf("AskCustomer")) {
                    Row() { Osyrin.Button("Ask the customer", onClick: AskCustomer); }
                  }
                }

                if (moves.Any(m => m.Event == "AskVendor")) {
                  Verb("Wait on a third party", "Same pause, different reason — and we keep chasing them, not the customer.",
                       DestOf("AskVendor"), BlockedOf("AskVendor")) {
                    Row(gap: 2, align: Align.Center) {
                      Input(value: vendor, label: "Who we are waiting on", placeholder: "Northwind");
                      // Without a name to wait on there is nothing to ask, so the button is unavailable rather
                      // than silently inert (`ui-inert-affordance`).
                      Osyrin.Button("Ask the vendor", onClick: AskVendor, disabled: vendor == "",
                                    whenDenied: "name who we are waiting on first");
                    }
                  }
                }

                if (moves.Any(m => m.Event == "CustomerReplied")) {
                  Verb("The customer wrote back", "The clock resumes from where it paused.",
                       DestOf("CustomerReplied"), BlockedOf("CustomerReplied")) {
                    Row() { Osyrin.Button("Customer replied", onClick: CustomerReplied); }
                  }
                }

                if (moves.Any(m => m.Event == "VendorReplied")) {
                  Verb("The vendor came back", "Back to work, with the resolution clock running again.",
                       DestOf("VendorReplied"), BlockedOf("VendorReplied")) {
                    Row() { Osyrin.Button("Vendor replied", onClick: VendorReplied); }
                  }
                }

                if (moves.Any(m => m.Event == "Close")) {
                  Verb("Close it", "Silence for seven days closes it anyway — this is saying so now.",
                       DestOf("Close"), BlockedOf("Close")) {
                    Row() { Osyrin.Button("Close ticket", onClick: Close); }
                  }
                }

                if (moves.Any(m => m.Event == "Reopen")) {
                  Verb("The fix did not hold", "A ticket that comes back keeps its history instead of becoming a new one.",
                       DestOf("Reopen"), BlockedOf("Reopen")) {
                    Row(gap: 2, align: Align.Center) {
                      TextArea(value: reason, label: "What went wrong", rows: 3, placeholder: "Still failing on card payments");
                      Osyrin.Button("Reopen ticket", onClick: Reopen);
                    }
                  }
                }

                if (moves.Any(m => m.Event == "Escalate")) {
                  Verb("Re-grade the severity",
                       "The SLA is re-snapshotted and every live clock re-based, so the new terms start now.",
                       DestOf("Escalate"), BlockedOf("Escalate")) {
                    Row() { Osyrin.Button(t.Severity == Severity.Gold ? "Downgrade to Basic" : "Upgrade to Gold", onClick: Escalate); }
                  }
                }

                if (moves.Count() > 0) {
                  Verb("It should never have existed", "A duplicate, spam, or withdrawn — not the same thing as closed.",
                       "", "") {
                    Row(gap: 2, align: Align.Center) {
                      Input(value: reason, label: "Why", placeholder: "Duplicate of #118");
                      // Same as "Ask the vendor": no reason typed, nothing to do, so say so on the control.
                      Osyrin.Button("Cancel ticket", onClick: Cancel, disabled: reason == "",
                                    whenDenied: "give a reason first");
                    }
                  }
                }

                if (moves.Count() == 0) {
                  Text("This ticket is finished — there is nothing left to do to it.",
                       fontSize: FontSize.Body, color: Colors.TextMuted);
                }
              }
            }
          }

          if (t.RootCause != null || t.Resolution != null || t.WaitingOnVendor != null
              || t.ReopenCount > 0 || t.ChasesSent > 0 || t.VendorChasesSent > 0 || t.CancelReason != null) {
            Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) {
              Stack(gap: 2) {
                Text("On the record", fontSize: FontSize.Section, fontWeight: FontWeight.Semibold, color: Colors.OnBg);
                if (t.RootCause != null)       { Fact("Root cause", t.RootCause); }
                if (t.Resolution != null)      { Fact("Resolution", t.Resolution); }
                if (t.WaitingOnVendor != null) { Fact("Waiting on", t.WaitingOnVendor); }
                if (t.CancelReason != null)    { Fact("Cancelled because", t.CancelReason); }
                if (t.ReopenCount > 0)         { Fact("Reopened", t.ReopenCount + " time(s)"); }
                if (t.ChasesSent > 0)          { Fact("Customer chased", t.ChasesSent + " time(s)"); }
                if (t.VendorChasesSent > 0)    { Fact("Vendor chased", t.VendorChasesSent + " time(s)"); }
              }
            }
          }
        }
      }

      if (tickets.Count() == 0) {
        Box(bg: Colors.Surface, rounded: Radius.Card, p: 5, borderW: 1, border: Colors.Border) {
          Stack(gap: 2, align: Align.Center) {
            Text("No such ticket", fontSize: FontSize.Section, fontWeight: FontWeight.Medium, color: Colors.OnBg);
            Link(href: "/") { Text("Back to the board", fontSize: FontSize.Body, color: Colors.Primary); }
          }
        }
      }
    }
  }
}

/// THE PROMISE, large. The same three bands and the same word as the board — drawn from the same projection, so the
/// two surfaces cannot drift into disagreeing about how a ticket is doing.
[Composable] component PromisePanel(DeskRow r) {
  live var breachesIn = r.BreachesAt - DateTime.UtcNow;

  render {
    Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) {
      Stack(gap: 3) {
        Row(align: Align.Center, gap: 3) {
          Stack(gap: 1) {
            Text(r.HasPromise ? r.Promise : "no live promise",
                 fontSize: FontSize.Section, fontWeight: FontWeight.Semibold, color: Colors.OnBg);
            if (r.HasPromise) {
              Text(SlaText(r.Elapsed, r.Budget), fontSize: FontSize.Caption, color: Colors.TextMuted);
            }
          }
          Spacer();
          if (r.HasPromise) {
            Stack(gap: 1, align: Align.End) {
              Text(SlaWord(breachesIn, r.Budget), fontSize: FontSize.Caption, fontWeight: FontWeight.Medium,
                   color: breachesIn.TotalSeconds < 0.0 ? Colors.Breached
                          : (breachesIn.TotalSeconds < r.Budget.TotalSeconds * 0.25 ? Colors.AtRisk : Colors.Ok));
              Text(SlaRemainingText(breachesIn), fontSize: FontSize.Body, fontWeight: FontWeight.Medium, fontVariant: FontVariant.TabularNums,
                   color: breachesIn.TotalSeconds < 0.0 ? Colors.Breached : Colors.OnBg);
            }
          }
        }
        if (r.HasPromise) {
          Box(h: "8px", w: "100%", rounded: Radius.Pill, bg: Colors.Sunken) {
            Box(h: "8px", rounded: Radius.Pill, w: SlaPercent(r.Elapsed, r.Budget) + "%",
                bg: breachesIn.TotalSeconds < 0.0 ? Colors.Breached
                    : (breachesIn.TotalSeconds < r.Budget.TotalSeconds * 0.25 ? Colors.AtRisk : Colors.Ok));
          }
        }
      }
    }
  }
}

/// THE RUN'S OWN TIMELINE, newest first. Nothing here is written by this app: `Audit` is the engine's record of what
/// it did, so a reminder that fired at 3am and a breach nobody was watching are both in it — which is exactly the
/// artifact ops reads in a dispute.
[Composable] component Conversation(ThreadRow[] messages) {
  render {
    Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) {
      Stack(gap: 3, role: UiRole.Region, label: "Conversation") {
        Text("Conversation", fontSize: FontSize.Section, fontWeight: FontWeight.Semibold, color: Colors.OnBg);
        if (messages.Count() == 0) {
          Text("Nobody has replied to this customer yet.", fontSize: FontSize.Caption, color: Colors.TextMuted);
        }
        Stack(gap: 3) {
          foreach (var m in messages) { MessageRow(m); }
        }
      }
    }
  }
}

/// One turn. The AUTHOR and the direction lead; the body is the content.
[Composable] component MessageRow(ThreadRow m) {
  render {
    Box(bg: m.IsOurs ? Colors.TintPrimary : Colors.Sunken, rounded: Radius.Card, p: 3) {
      Stack(gap: 2) {
        Row(align: Align.Center, gap: 2) {
          Text(m.Way, fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.OnBg);
          if (m.HasAuthor) { Text(m.Author, fontSize: FontSize.Caption, color: Colors.TextMuted); }
          Spacer();
          Text(Ago(DateTime.UtcNow - m.At) + " ago", fontSize: FontSize.Caption, color: Colors.TextMuted);
        }
        Markdown(m.Body);
      }
    }
  }
}

[Composable] component Timeline(Osyrin.Workflow.WorkflowAuditEntry[] entries, Person[] roster) {
  render {
    Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) {
      Stack(gap: 3) {
        Text("History", fontSize: FontSize.Section, fontWeight: FontWeight.Semibold, color: Colors.OnBg);
        if (entries.Count() == 0) {
          Text("Nothing has happened to this ticket yet.", fontSize: FontSize.Caption, color: Colors.TextMuted);
        }
        Stack(gap: 2) {
          foreach (var e in entries.OrderByDescending(x => x.At)) { TimelineRow(e, roster); }
        }
      }
    }
  }
}

/// One event.
[Composable] component TimelineRow(Osyrin.Workflow.WorkflowAuditEntry e, Person[] roster) {
  render {
    Row(gap: 3, align: Align.Start) {
      Text(e.Kind, fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.OnBg, w: "110px");
      Stack(gap: 0, grow: 1, minW: "0") {
        Text(e.Slot == null ? "the ticket" : e.Slot, fontSize: FontSize.Caption, color: Colors.TextMuted);
        if (e.Message != null) { Text(e.Message, fontSize: FontSize.Caption, color: Colors.TextMuted); }
      }
      Text(ActorName(e.Actor, roster), fontSize: FontSize.Caption, color: Colors.TextMuted, w: "130px");
      Text(Ago(DateTime.UtcNow - e.At) + " ago", fontSize: FontSize.Caption, color: Colors.TextMuted, fontVariant: FontVariant.TabularNums);
    }
  }
}

/// WHO HOLDS THIS TICKET, and the roster it could go to.
[Composable] component HandoverPanel(DeskRow[] rows, Action onTake, Action onHandBack) {
  render {
    Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) {
      Stack(gap: 3, role: UiRole.Region, label: "Who holds this") {
        Row(align: Align.Center, gap: 3) {
          Text("Who holds this", fontSize: FontSize.Section, fontWeight: FontWeight.Semibold, color: Colors.OnBg);
          Spacer();
          foreach (var r in rows) {
            if (r.HasAssignee) {
              Row(align: Align.Center, gap: 2) {
                Holder(r.AssigneeName, r.AssigneeInitials);
                Osyrin.Button("Hand it back", onClick: onHandBack);
              }
            } else {
              Row(align: Align.Center, gap: 2) {
                Unheld();
                Osyrin.Button("Take it", onClick: onTake, bg: Colors.Primary, color: Colors.OnPrimary, fontWeight: FontWeight.Medium);
              }
            }
          }
        }

        Text("Anyone on the support desk can pick this up — the roster is on the board.",
             fontSize: FontSize.Caption, color: Colors.TextMuted);
      }
    }
  }
}

/// ONE TAB. A pill says "filter"; a tab says "the page continues here, under this heading" — and these switch which
/// PANEL is showing rather than narrowing a list, which is the board's job and the board's idiom.
[Composable] component Tab(string label, string name, bool on, Action onPick) {
  render {
    Pressable(onClick: onPick, label: name, selected: on) {
      Stack(gap: 2) {
        Text(label, fontSize: FontSize.Body, fontWeight: on ? FontWeight.Semibold : FontWeight.Medium, color: on ? Colors.OnBg : Colors.TextMuted);
        Box(h: "2px", rounded: Radius.Pill, bg: on ? Colors.Primary : "transparent");
      }
    }
  }
}

/// One verb: what it is, what it means, WHERE IT LANDS, and its own controls. The EXPLANATION is not decoration — a
/// support desk's states differ by what happens on silence, and that is invisible from a button label.
[Composable] component Verb(string title, string note, string dest, string blocked) {
  render {
    Stack(gap: 2, pb: 2) {
      Stack(gap: 1) {
        Row(gap: 2, align: Align.Center) {
          Text(title, fontSize: FontSize.Body, fontWeight: FontWeight.Medium, color: Colors.OnBg);
          if (dest != "") {
            Text("→ " + dest, fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.TextMuted,
                 px: 2, py: 1, bg: Colors.Sunken, rounded: Radius.Pill);
          }
        }
        Text(note, fontSize: FontSize.Caption, color: Colors.TextMuted);
        if (blocked != "") { Text(blocked, fontSize: FontSize.Caption, color: Colors.AtRisk); }
      }
      Slot;
    }
  }
}

/// A label and its value, on one line.
[Composable] component Fact(string label, string value) {
  render {
    Row(gap: 2, align: Align.Start) {
      Text(label, fontSize: FontSize.Caption, color: Colors.TextMuted, w: "140px");
      Text(value, fontSize: FontSize.Caption, color: Colors.OnBg);
    }
  }
}

/// The run's timeline for one ticket. A server read, so the page can re-ask for it after an act that the live read
/// does not see (a claim moves a SLOT, not the run).
/// ONE TURN, PROJECTED. The page renders a class, not the `Message` entity — the same shape `DeskRow` has and for
/// the same reason: a render slot reads plain VALUES, and an entity handed to one carries fields the client cannot
/// resolve (the panel rendered its heading and then stopped, on a list that was demonstrably non-empty).
class ThreadRow {
  public string Author;
  public bool HasAuthor;
  public string Way;
  public string Body;
  public DateTime At;
  /// Did WE send it. The panel says so in words as well — this only tints, so the thread can be scanned for "what
  /// did we tell them" without reading every label, and stays legible to someone who cannot use the tint.
  public bool IsOurs;
}

/// THE CONVERSATION — what was actually SAID, oldest first.
List<ThreadRow> ConversationOf(Guid id) {
  var rows = new List<ThreadRow>();
  foreach (var m in Message.Include(m => m.Author).Where(m => m.Ticket.Id == id).OrderBy(m => m.At)) {
    rows.Add(new ThreadRow {
      Way = m.Direction == MessageDirection.Outbound ? "We replied" : "They wrote in",
      HasAuthor = m.Author != null,
      Author = m.Author == null ? "" : m.Author.Name,
      IsOurs = m.Direction == MessageDirection.Outbound,
      Body = m.Body, At = m.At,
    });
  }
  return rows;
}

Osyrin.Workflow.WorkflowAuditEntry[] HistoryOf(Guid id) {
  var t = Ticket.Single(x => x.Id == id);
  return SupportTicket.For(t).Audit;
}

/// THE ROW THE PANEL IS COUNTING DOWN — the engine's GOVERNING slot for this ticket, resolved here so that "who
/// holds it" and "take it" can never mean two different rows.
void DoClaimGoverning(Guid ticketId) {
  var row = GoverningRow(ticketId);
  if (row == null) { return; }
  Workflow.Claim(row);
}

/// HAND BACK THE SLOT YOU ARE HOLDING — which is not the same question as "which slot governs", and must not be
/// asked that way.
void DoAssign(Guid ticketId, Guid agentId) {
  var held = Workflow.Work<Ticket>().Include(r => r.Item)
    .Where(r => r.Item.Id == ticketId && r.Assignee != null)
    .OrderBy(r => r.BreachesAt).FirstOrDefault();
  var row = held ?? GoverningRow(ticketId);
  if (row == null) { return; }
  var target = Agent.Where(a => a.Id == agentId).FirstOrDefault();
  if (target == null) { return; }
  Workflow.Assign(row, target);
}

void DoReleaseMine(Guid ticketId) {
  var me = Session.CurrentUser;
  if (me == null) { return; }
  var mine = me.Id;
  var row = Workflow.Work<Ticket>().Include(r => r.Item)
    .Where(r => r.Item.Id == ticketId && r.Assignee == mine).FirstOrDefault();
  if (row == null) { return; }
  Workflow.Release(row);
}

/// The per-slot row for the ticket's GOVERNING slot — the engine's own answer, the same one `Desk()` reads, so "who
/// holds this", "take it" and "give it to Sue" can never mean three different slots.
Osyrin.WorkRow_Ticket GoverningRow(Guid ticketId) {
  var item = Workflow.WorkByItem<Ticket>().Include(r => r.Item).Where(r => r.Item.Id == ticketId).FirstOrDefault();
  if (item == null) { return null; }
  var slot = item.GoverningSlot;
  if (slot == null) { return null; }
  return Workflow.Work<Ticket>().Include(r => r.Item)
    .Where(r => r.Item.Id == ticketId && r.SlotAlias == slot).FirstOrDefault();
}

/// Raise the reply, and KEEP IT — in that order, so a refused deposit writes no message. A thread showing an answer
/// the workflow turned away would be worse than one that lost it.
void DoRespond(Ticket t, string reply) {
  SupportTicket.RaiseRespond(t, reply);
  new Message { Ticket = t, Body = reply, Direction = MessageDirection.Outbound,
                At = DateTime.UtcNow, Author = Session.CurrentUser };
}
void DoResolve(Ticket t, string summary) { SupportTicket.RaiseResolve(t, summary); }
void DoAskCustomer(Ticket t)             { SupportTicket.RaiseAskCustomer(t); }
void DoCustomerReplied(Ticket t)         { SupportTicket.RaiseCustomerReplied(t); }
void DoAskVendor(Ticket t, string who)   { SupportTicket.RaiseAskVendor(t, who); }
void DoVendorReplied(Ticket t)           { SupportTicket.RaiseVendorReplied(t); }
void DoClose(Ticket t)                   { SupportTicket.RaiseClose(t); }
void DoReopen(Ticket t, string why)      { SupportTicket.RaiseReopen(t, why); }
void DoCancel(Ticket t, string why)      { SupportTicket.RaiseCancel(t, why); }
void DoEscalate(Ticket t, Severity s)    { SupportTicket.RaiseEscalate(t, s); }