Skip to content

Workspace Platform ​

A serious application graph, not a counter enlarged with CSS.

Orbit is a compact ClickUp-style workspace with four projects, five members, and a connected task graph. Switch between list and board views, filter by person or priority, drag cards between statuses, create a task, open its detail panel, edit fields, complete checklist items, and add a comment. Every surface updates from the same ivue objects.

Open in StackBlitz ⚡

One graph, several interfaces ​

The example deliberately renders the same task objects through independent surfaces:

  • The sidebar selects a project and derives its task count.
  • List and board views reorganize the same Task.Class instances.
  • Filters combine project, priority, assignee, tags, title, and id.
  • The workload panel derives hours from every incomplete assigned task.
  • The detail drawer edits the selected task directly.
  • Task actions append to the workspace activity stream.

No view synchronizes a duplicate copy. Moving a board card changes its task's status ref; list groups, progress, workload, and activity derive the result.

Domain ownership stays visible ​

Workspace owns collection membership and application selection. Each Task owns its mutable fields and retains its workspace owner for related lookups and activity:

ts
class $Task {
  constructor(
    readonly workspace: Task.Owner,
    seed: TaskSeed,
    dueDate: string,
  ) {}

  get status() {
    return ref<TaskStatus>('backlog');
  }

  get assignee() {
    return this.workspace.memberById(this.assigneeId.value);
  }

  setStatus(status: TaskStatus) {
    this.status.value = status;
    this.workspace.recordActivity(/* ... */);
  }
}

The relationship is explicit. ivue supplies reactivity and stable class namespaces; it does not hide ownership behind a store registry or injection container.

Each template has one logic owner ​

The domain graph does not push view behavior back into <script setup>. Each behavioral surface has its own small ivue class: TaskList.Class owns list actions, TaskBoard.Class owns drag state, and TaskDetails.Class owns form state and DOM-event normalization. Their SFCs only wire the class to the template:

ts
const props = defineProps<{ task: Task.Model }>();
const details = new TaskDetails.Class(props);
const { comment } = details;

Adding behavior means updating or extending that class, not growing a second layer of free functions beside it. Domain ownership and view ownership remain separate, but neither is ambiguous.

Costs follow the state shape ​

Scalar task fields use ref getters. Collection membership uses shallowRef because additions and removals replace the array, while each task already owns its reactive fields. Aggregates remain plain getters:

ts
get filteredTasks() {
  return this.projectTasks.filter((task) => {
    if (this.priorityFilter.value !== 'all' &&
        task.priority.value !== this.priorityFilter.value) return false;

    return task.title.value.toLowerCase().includes(this.search.value);
  });
}

There is no computed allocated for every possible dashboard number. A getter derives when a rendered surface reads it, and Vue tracks the leaf refs reached by that execution.

The namespace seam is already installed ​

Every domain constructor uses the same upgrade-ready shape:

ts
export namespace Task {
  export const $Class = $Task;
  export let Class = Reactive($Class);
  export type Model = InstanceType<typeof Class>;
  export type Instance = typeof Class.Instance;
}

The workspace constructs new Task.Class(...), never the hidden declaration. Tests or a future boot-time kernel can select an extension without rewriting consumers.

The source ​

ts
import { ref, shallowRef } from 'vue';
import { Reactive } from '../../ivue';
import { Static } from '../../Static';
import { Member } from './Member';
import { Project } from './Project';
import { Task } from './Task';
import { Seeds } from './Seeds';
import {
  STATUS_ORDER,
  type ActivityEntry,
  type TaskPriority,
  type TaskStatus,
  type WorkspaceView
} from './types';

class $Workspace {
  /** The app-wide workspace singleton — a `$`-static: constructed on
   *  first use through the namespace slot, after the app exists. */
  protected static get $shared(): Workspace.Model {
    return new Workspace.Class();
  }

  /** The app-wide workspace singleton, created on first use. */
  static use(): Workspace.Model {
    return this.$shared;
  }

  protected static dateAtOffset(offset: number) {
    const date = new Date();
    date.setHours(12, 0, 0, 0);
    date.setDate(date.getDate() + offset);
    return date.toISOString().slice(0, 10);
  }

  constructor() {
    this.reset();
  }

  /** The one cast per class: instance code reads its own statics here. */
  protected get self() {
    return this.constructor as typeof $Workspace;
  }

  get name() {
    return ref('Orbit Labs');
  }

  get projects() {
    return shallowRef<Project.Model[]>([]);
  }

  get members() {
    return shallowRef<Member.Model[]>([]);
  }

  get tasks() {
    return shallowRef<Task.Model[]>([]);
  }

  get activities() {
    return shallowRef<ActivityEntry[]>([]);
  }

  get selectedProjectId() {
    return ref<string | 'all'>('launch');
  }

  get selectedTaskId() {
    return ref<string | null>(null);
  }

  get view() {
    return ref<WorkspaceView>('list');
  }

  get search() {
    return ref('');
  }

  get priorityFilter() {
    return ref<TaskPriority | 'all'>('all');
  }

  get assigneeFilter() {
    return ref<string | 'all'>('all');
  }

  get nextTaskNumber() {
    return ref(500);
  }

  get selectedProject() {
    if (this.selectedProjectId.value === 'all') return undefined;
    return this.projectById(this.selectedProjectId.value);
  }

  get selectedTask() {
    return this.tasks.value.find((task) => task.id === this.selectedTaskId.value);
  }

  get isAllSelected() {
    return this.selectedProjectId.value === 'all';
  }

  get isListView() {
    return this.view.value === 'list';
  }

  get selectedProjectIcon() {
    return this.selectedProject?.icon ?? '◎';
  }

  get selectedProjectColor() {
    return this.selectedProject?.color ?? '#6366f1';
  }

  get title() {
    return this.selectedProject?.name.value ?? 'Everything';
  }

  get projectTasks() {
    if (this.selectedProjectId.value === 'all') return this.tasks.value;
    return this.tasks.value.filter((task) => task.projectId === this.selectedProjectId.value);
  }

  get filteredTasks() {
    const query = this.search.value.trim().toLowerCase();
    return this.projectTasks.filter((task) => {
      if (
        this.priorityFilter.value !== 'all' &&
        task.priority.value !== this.priorityFilter.value
      ) {
        return false;
      }
      if (
        this.assigneeFilter.value !== 'all' &&
        task.assigneeId.value !== this.assigneeFilter.value
      ) {
        return false;
      }
      if (!query) return true;
      return [task.id, task.title.value, task.assignee?.name ?? '', ...task.tags.value].some(
        (value) => value.toLowerCase().includes(query)
      );
    });
  }

  get completedCount() {
    return this.projectTasks.filter((task) => task.status.value === 'done').length;
  }

  get completionRate() {
    if (this.projectTasks.length === 0) return 0;
    return Math.round((this.completedCount / this.projectTasks.length) * 100);
  }

  get activeCount() {
    return this.projectTasks.filter((task) => task.status.value === 'in-progress').length;
  }

  get overdueCount() {
    return this.projectTasks.filter((task) => task.isOverdue).length;
  }

  get completionWidth() {
    return `${this.completionRate}%`;
  }

  get hasOverdue() {
    return this.overdueCount > 0;
  }

  get hasNoTasks() {
    return this.filteredTasks.length === 0;
  }

  get filterCount() {
    return (
      Number(this.priorityFilter.value !== 'all') +
      Number(this.assigneeFilter.value !== 'all') +
      Number(Boolean(this.search.value.trim()))
    );
  }

  readonly statusOrder = STATUS_ORDER;

  memberById(memberId: string) {
    return this.members.value.find((member) => member.id === memberId);
  }

  projectById(projectId: string) {
    return this.projects.value.find((project) => project.id === projectId);
  }

  taskCountForProject(projectId: string) {
    return this.tasks.value.filter((task) => task.projectId === projectId).length;
  }

  tasksByStatus(status: TaskStatus) {
    return this.filteredTasks.filter((task) => task.status.value === status);
  }

  workloadFor(memberId: string) {
    return this.tasks.value
      .filter((task) => task.assigneeId.value === memberId && task.status.value !== 'done')
      .reduce((hours, task) => hours + task.estimateHours.value, 0);
  }

  workloadPercent(member: Member.Model) {
    return Math.min(100, Math.round((this.workloadFor(member.id) / member.capacity.value) * 100));
  }

  /** Whether a project is the selected one (a per-item template condition). */
  isSelected(project: Project.Model) {
    return this.selectedProjectId.value === project.id;
  }

  workloadWidth(member: Member.Model) {
    return `${this.workloadPercent(member)}%`;
  }

  showList() {
    this.view.value = 'list';
  }

  showBoard() {
    this.view.value = 'board';
  }

  selectProject(projectId: string | 'all') {
    this.selectedProjectId.value = projectId;
    this.selectedTaskId.value = null;
  }

  openTask(taskId: string) {
    this.selectedTaskId.value = taskId;
  }

  closeTask() {
    this.selectedTaskId.value = null;
  }

  addTask(title: string) {
    const trimmed = title.trim();
    if (!trimmed) return;
    const projectId =
      this.selectedProjectId.value === 'all'
        ? this.projects.value[0].id
        : this.selectedProjectId.value;
    const id = `OR-${++this.nextTaskNumber.value}`;
    const task = new Task.Class(
      this,
      {
        id,
        projectId,
        title: trimmed,
        description: 'Add the details and acceptance criteria for this task.',
        status: 'backlog',
        priority: 'normal',
        assigneeId: 'you',
        dueOffset: 7,
        estimateHours: 3,
        tags: ['new']
      },
      this.self.dateAtOffset(7)
    );
    this.tasks.value = [task, ...this.tasks.value];
    this.recordActivity('you', '+', `created ${trimmed}`);
    this.openTask(id);
  }

  recordActivity(actorId: string, icon: string, text: string) {
    this.activities.value = [
      {
        id: Date.now() + Math.random(),
        actorId,
        icon,
        text,
        createdAt: 'Just now'
      },
      ...this.activities.value
    ].slice(0, 12);
  }

  clearFilters() {
    this.search.value = '';
    this.priorityFilter.value = 'all';
    this.assigneeFilter.value = 'all';
  }

  reset() {
    this.projects.value = Seeds.Class.projects.map((seed) => new Project.Class(seed));
    this.members.value = Seeds.Class.members.map((seed) => new Member.Class(seed));
    this.tasks.value = Seeds.Class.tasks.map(
      (seed) => new Task.Class(this, seed, this.self.dateAtOffset(seed.dueOffset))
    );
    this.activities.value = Seeds.Class.activities.map((entry, index) => ({
      ...entry,
      id: index + 1
    }));
    this.selectedProjectId.value = 'launch';
    this.selectedTaskId.value = null;
    this.view.value = 'list';
    this.clearFilters();
    this.nextTaskNumber.value = 500;
  }
}

export namespace Workspace {
  export const $Class = Static($Workspace); // anchor — it declares statics
  export let Class = Reactive($Class);
  export type Model = InstanceType<typeof Class>;
  export type Instance = typeof Class.Instance;
}
ts
import { ref, shallowRef } from 'vue';
import { Reactive } from '../../ivue';
import type { Member } from './Member';
import type { Project } from './Project';
import {
  PRIORITY_META,
  STATUS_META,
  type ChecklistItem,
  type TaskComment,
  type TaskPriority,
  type TaskSeed,
  type TaskStatus
} from './types';

class $Task {
  constructor(workspace: Task.Owner, seed: TaskSeed, dueDate: string) {
    this.workspace = workspace;
    this.id = seed.id;
    this.projectId = seed.projectId;
    this.title.value = seed.title;
    this.description.value = seed.description;
    this.status.value = seed.status;
    this.priority.value = seed.priority;
    this.assigneeId.value = seed.assigneeId;
    this.dueDate.value = dueDate;
    this.estimateHours.value = seed.estimateHours;
    this.tags.value = [...seed.tags];
    this.checklist.value = (seed.checklist ?? []).map((item) => ({ ...item }));
    this.comments.value = (seed.comments ?? []).map((comment) => ({
      ...comment
    }));
  }

  readonly id: string;

  readonly projectId: string;

  readonly workspace: Task.Owner;

  get title() {
    return ref('');
  }

  get description() {
    return ref('');
  }

  get status() {
    return ref<TaskStatus>('backlog');
  }

  get priority() {
    return ref<TaskPriority>('normal');
  }

  get assigneeId() {
    return ref('');
  }

  get dueDate() {
    return ref('');
  }

  get estimateHours() {
    return ref(0);
  }

  get tags() {
    return shallowRef<string[]>([]);
  }

  get checklist() {
    return shallowRef<ChecklistItem[]>([]);
  }

  get comments() {
    return shallowRef<TaskComment[]>([]);
  }

  get assignee() {
    return this.workspace.memberById(this.assigneeId.value);
  }

  get project() {
    return this.workspace.projectById(this.projectId);
  }

  get completedChecklistCount() {
    return this.checklist.value.filter((item) => item.done).length;
  }

  get checklistProgress() {
    if (this.checklist.value.length === 0) return 0;
    return Math.round((this.completedChecklistCount / this.checklist.value.length) * 100);
  }

  get checklistBarStyle() {
    return { width: `${this.checklistProgress}%` };
  }

  get priorityColor() {
    return PRIORITY_META[this.priority.value].color;
  }

  get priorityTitle() {
    return `${PRIORITY_META[this.priority.value].label} priority`;
  }

  get dueDayOffset() {
    const due = new Date(`${this.dueDate.value}T12:00:00`);
    const today = new Date();
    today.setHours(12, 0, 0, 0);
    return Math.round((due.getTime() - today.getTime()) / 86_400_000);
  }

  get isOverdue() {
    return this.status.value !== 'done' && this.dueDayOffset < 0;
  }

  get dueLabel() {
    if (this.status.value === 'done') return 'Complete';
    if (this.dueDayOffset === 0) return 'Today';
    if (this.dueDayOffset === 1) return 'Tomorrow';
    if (this.dueDayOffset === -1) return '1 day overdue';
    if (this.dueDayOffset < -1) return `${Math.abs(this.dueDayOffset)} days overdue`;
    return new Intl.DateTimeFormat('en', {
      month: 'short',
      day: 'numeric'
    }).format(new Date(`${this.dueDate.value}T12:00:00`));
  }

  setStatus(status: TaskStatus) {
    if (status === this.status.value) return;
    this.status.value = status;
    this.workspace.recordActivity(
      'you',
      '→',
      `moved ${this.title.value} to ${STATUS_META[status].label}`
    );
  }

  setPriority(priority: TaskPriority) {
    if (priority === this.priority.value) return;
    this.priority.value = priority;
    this.workspace.recordActivity(
      'you',
      '!',
      `set ${this.title.value} to ${PRIORITY_META[priority].label} priority`
    );
  }

  setAssignee(memberId: string) {
    if (memberId === this.assigneeId.value) return;
    this.assigneeId.value = memberId;
    const member = this.workspace.memberById(memberId);
    this.workspace.recordActivity(
      'you',
      '@',
      `assigned ${this.title.value} to ${member?.name ?? 'Unassigned'}`
    );
  }

  toggleChecklist(itemId: string) {
    this.checklist.value = this.checklist.value.map((item) =>
      item.id === itemId ? { ...item, done: !item.done } : item
    );
    this.workspace.recordActivity('you', '✓', `updated the checklist on ${this.title.value}`);
  }

  addComment(body: string) {
    const trimmed = body.trim();
    if (!trimmed) return;
    this.comments.value = [
      ...this.comments.value,
      {
        id: `comment-${Date.now()}`,
        authorId: 'you',
        body: trimmed,
        createdAt: 'Just now'
      }
    ];
    this.workspace.recordActivity('you', '+', `commented on ${this.title.value}`);
  }
}

export namespace Task {
  export const $Class = $Task;
  export let Class = Reactive($Class);
  export type Model = InstanceType<typeof Class>;
  export type Instance = typeof Class.Instance;

  /* Types */

  /** What a task needs from its owner — the workspace, seen narrowly. */
  export interface Owner {
    memberById(memberId: string): Member.Model | undefined;
    projectById(projectId: string): Project.Model | undefined;
    recordActivity(actorId: string, icon: string, text: string): void;
  }
}
ts
import { ref } from 'vue';
import { Reactive } from '../../ivue';
import type { MemberSeed } from './types';

class $Member {
  constructor(seed: MemberSeed) {
    this.id = seed.id;
    this.name = seed.name;
    this.initials = seed.initials;
    this.role = seed.role;
    this.color = seed.color;
    this.capacity.value = seed.capacity;
    this.online.value = seed.online;
  }

  readonly id: string;

  readonly name: string;

  readonly initials: string;

  readonly role: string;

  readonly color: string;

  get capacity() {
    return ref(0);
  }

  get online() {
    return ref(false);
  }
}

export namespace Member {
  export const $Class = $Member;
  export let Class = Reactive($Class);
  export type Model = InstanceType<typeof Class>;
  export type Instance = typeof Class.Instance;
}
ts
import { ref } from 'vue';
import { Reactive } from '../../ivue';
import type { ProjectSeed } from './types';

class $Project {
  constructor(seed: ProjectSeed) {
    this.id = seed.id;
    this.icon = seed.icon;
    this.color = seed.color;
    this.name.value = seed.name;
  }

  readonly id: string;

  readonly icon: string;

  readonly color: string;

  get name() {
    return ref('');
  }
}

export namespace Project {
  export const $Class = $Project;
  export let Class = Reactive($Class);
  export type Model = InstanceType<typeof Class>;
  export type Instance = typeof Class.Instance;
}
ts
import { ref } from 'vue';
import { Reactive } from '../../ivue';
import { Workspace } from './Workspace';

class $WorkspacePlatformExample {
  get creatingTask() {
    return ref(false);
  }

  get newTaskTitle() {
    return ref('');
  }

  protected get $workspace() {
    return Workspace.Class.use();
  }

  get workspace() {
    return this.$workspace;
  }

  get isTaskSubmitDisabled() {
    return !this.newTaskTitle.value.trim();
  }

  toggleTaskCreation() {
    this.creatingTask.value = !this.creatingTask.value;
  }

  cancelTaskCreation() {
    this.creatingTask.value = false;
  }

  submitTask() {
    this.workspace.addTask(this.newTaskTitle.value);
    this.newTaskTitle.value = '';
    this.creatingTask.value = false;
  }
}

export namespace WorkspacePlatformExample {
  export const $Class = $WorkspacePlatformExample;
  export let Class = Reactive($Class);
  export type Model = InstanceType<typeof Class>;
  export type Instance = typeof Class.Instance;
}
vue
<script setup lang="ts">
import ActivityPanel from './ActivityPanel.vue';
import MemberAvatar from './MemberAvatar.vue';
import TaskBoard from './TaskBoard.vue';
import TaskDetails from './TaskDetails.vue';
import TaskList from './TaskList.vue';
import { PRIORITY_META } from './types';
import { WorkspacePlatformExample as WorkspacePlatformExampleModel } from './WorkspacePlatformExample';
import './workspace.css';

const example = new WorkspacePlatformExampleModel.Class();
const workspace = example.workspace;
const { assigneeFilter, priorityFilter, search } = workspace;
const { creatingTask, newTaskTitle } = example;
</script>

<template>
  <div class="ow-example">
    <div class="ow-app">
      <aside class="ow-sidebar">
        <header class="ow-brand">
          <span>O</span>
          <strong>Orbit</strong>
          <button type="button" aria-label="Workspace menu">
            <span class="ow-symbol">⌄</span>
          </button>
        </header>

        <label class="ow-global-search">
          <span>⌕</span>
          <input v-model="search" placeholder="Search workspace" />
          <kbd>⌘ K</kbd>
        </label>

        <nav class="ow-primary-nav">
          <button type="button"><span class="ow-symbol">⌂</span>Home</button>
          <button type="button" class="active">
            <span class="ow-symbol">✓</span>My work<i>5</i>
          </button>
          <button type="button">
            <span class="ow-symbol">◷</span>Inbox<i
              class="alert"
              aria-label="3 unread notifications"
            ></i>
          </button>
          <button type="button"><span class="ow-symbol">▦</span>Dashboards</button>
        </nav>

        <section class="ow-project-nav">
          <header>
            <span>Projects</span
            ><button type="button" aria-label="Add project">
              <span class="ow-symbol">+</span>
            </button>
          </header>
          <button
            type="button"
            :class="{ active: workspace.isAllSelected }"
            @click="workspace.selectProject('all')"
          >
            <i class="ow-everything ow-symbol">◎</i>
            <span>Everything</span>
            <small>{{ workspace.tasks.value.length }}</small>
          </button>
          <button
            v-for="project in workspace.projects.value"
            :key="project.id"
            type="button"
            :class="{ active: workspace.isSelected(project) }"
            @click="workspace.selectProject(project.id)"
          >
            <i class="ow-symbol" :style="{ color: project.color }">{{ project.icon }}</i>
            <span>{{ project.name.value }}</span>
            <small>{{ workspace.taskCountForProject(project.id) }}</small>
          </button>
        </section>

        <section class="ow-workload">
          <header><span>Team workload</span><small>This week</small></header>
          <div v-for="member in workspace.members.value.slice(0, 4)" :key="member.id">
            <MemberAvatar :member="member" size="small" />
            <i><b :style="{ width: workspace.workloadWidth(member) }"></b></i>
            <small>{{ workspace.workloadFor(member.id) }}h</small>
          </div>
        </section>

        <footer class="ow-sidebar-user">
          <MemberAvatar :member="workspace.memberById('you')" />
          <span class="ow-sidebar-user__copy"
            ><strong>Alex Morgan</strong><small>Workspace lead</small></span
          >
          <button type="button" aria-label="User menu">
            <span class="ow-symbol">•••</span>
          </button>
        </footer>
      </aside>

      <main class="ow-main">
        <header class="ow-topbar">
          <div>
            <span>Orbit Labs</span><i>/</i><strong>{{ workspace.title }}</strong>
          </div>
          <div class="ow-topbar__people">
            <MemberAvatar
              v-for="member in workspace.members.value.slice(0, 4)"
              :key="member.id"
              :member="member"
              size="small"
            />
            <button type="button" title="Reset example" @click="workspace.reset()">
              <span class="ow-symbol">↻</span>
            </button>
            <button type="button" class="ow-share">Share</button>
          </div>
        </header>

        <div class="ow-page-head">
          <div class="ow-page-title">
            <span class="ow-page-icon" :style="{ background: workspace.selectedProjectColor }">
              {{ workspace.selectedProjectIcon }}
            </span>
            <div>
              <small>PROJECT</small>
              <h2>{{ workspace.title }}</h2>
              <p>Plan the work, see ownership, and keep the team moving.</p>
            </div>
          </div>

          <div class="ow-metrics">
            <article>
              <span>Progress</span>
              <strong>{{ workspace.completionRate }}%</strong>
              <i><b :style="{ width: workspace.completionWidth }"></b></i>
            </article>
            <article>
              <span>Active</span><strong>{{ workspace.activeCount }}</strong
              ><small>in flight</small>
            </article>
            <article :class="{ danger: workspace.hasOverdue }">
              <span>Overdue</span><strong>{{ workspace.overdueCount }}</strong
              ><small>needs attention</small>
            </article>
          </div>
        </div>

        <div class="ow-viewbar">
          <div class="ow-view-tabs">
            <button
              type="button"
              :class="{ active: workspace.isListView }"
              @click="workspace.showList()"
            >
              <span class="ow-symbol">☷</span> List
            </button>
            <button
              type="button"
              :class="{ active: !workspace.isListView }"
              @click="workspace.showBoard()"
            >
              <span class="ow-symbol">▦</span> Board
            </button>
          </div>
          <div class="ow-view-actions">
            <select v-model="assigneeFilter" aria-label="Filter by assignee">
              <option value="all">All assignees</option>
              <option v-for="member in workspace.members.value" :key="member.id" :value="member.id">
                {{ member.name }}
              </option>
            </select>
            <select v-model="priorityFilter" aria-label="Filter by priority">
              <option value="all">All priorities</option>
              <option v-for="(meta, priority) in PRIORITY_META" :key="priority" :value="priority">
                {{ meta.label }} priority
              </option>
            </select>
            <button v-if="workspace.filterCount" type="button" @click="workspace.clearFilters()">
              Clear {{ workspace.filterCount }}
            </button>
            <button type="button" class="ow-new-task" @click="example.toggleTaskCreation()">
              <span class="ow-symbol">+</span> New task
            </button>
          </div>
        </div>

        <form v-if="creatingTask" class="ow-quick-add" @submit.prevent="example.submitTask()">
          <span>+</span>
          <input v-model="newTaskTitle" autofocus placeholder="What needs to be done?" />
          <small>Backlog · Assigned to you</small>
          <button type="button" @click="example.cancelTaskCreation()">Cancel</button>
          <button type="submit" :disabled="example.isTaskSubmitDisabled">Create task</button>
        </form>

        <div class="ow-content">
          <div class="ow-work-surface">
            <TaskList v-if="workspace.isListView" />
            <TaskBoard v-else />
            <div v-if="workspace.hasNoTasks" class="ow-no-results">
              <span>⌕</span>
              <strong>No tasks match these filters</strong>
              <button type="button" @click="workspace.clearFilters()">Clear filters</button>
            </div>
          </div>
          <ActivityPanel />
        </div>
      </main>

      <TaskDetails v-if="workspace.selectedTask" :task="workspace.selectedTask" />
    </div>
  </div>
</template>
ts
import { Reactive } from '../../ivue';
import type { Task } from './Task';
import type { TaskStatus } from './types';
import { Workspace } from './Workspace';

class $TaskList {
  protected get $workspace() {
    return Workspace.Class.use();
  }

  tasksByStatus(status: TaskStatus) {
    return this.$workspace.tasksByStatus(status);
  }

  hasTasks(status: TaskStatus) {
    return this.tasksByStatus(status).length > 0;
  }

  isComplete(status: TaskStatus) {
    return status === 'done';
  }

  openTask(task: Task.Model) {
    this.$workspace.openTask(task.id);
  }

  changeStatus(task: Task.Model, event: Event) {
    task.setStatus((event.target as HTMLSelectElement).value as TaskStatus);
  }
}

export namespace TaskList {
  export const $Class = $TaskList;
  export let Class = Reactive($Class);
  export type Model = InstanceType<typeof Class>;
  export type Instance = typeof Class.Instance;
}
vue
<script setup lang="ts">
import MemberAvatar from './MemberAvatar.vue';
import { TaskList as TaskListModel } from './TaskList';
import { PRIORITY_META, STATUS_META, STATUS_ORDER } from './types';

const list = new TaskListModel.Class();
</script>

<template>
  <div class="ow-list">
    <section v-for="status in STATUS_ORDER" :key="status" class="ow-list-group">
      <header class="ow-list-group__head">
        <span class="ow-status-dot" :style="{ background: STATUS_META[status].color }"></span>
        <strong>{{ STATUS_META[status].label }}</strong>
        <span>{{ list.tasksByStatus(status).length }}</span>
        <i></i>
      </header>

      <button
        v-for="task in list.tasksByStatus(status)"
        :key="task.id"
        type="button"
        class="ow-task-row"
        @click="list.openTask(task)"
      >
        <span class="ow-task-row__check" :class="{ done: list.isComplete(status) }">
          <span v-if="list.isComplete(status)" class="ow-symbol">✓</span>
        </span>
        <span class="ow-task-row__main">
          <strong>{{ task.title.value }}</strong>
          <small>{{ task.id }} · {{ task.project?.name.value }}</small>
        </span>
        <span class="ow-task-row__tags">
          <em v-for="tag in task.tags.value.slice(0, 2)" :key="tag" :data-tag="tag">
            {{ tag }}
          </em>
        </span>
        <span
          class="ow-priority"
          :style="{ '--priority': PRIORITY_META[task.priority.value].color }"
        >
          <i></i>{{ PRIORITY_META[task.priority.value].label }}
        </span>
        <span class="ow-task-row__due" :class="{ overdue: task.isOverdue }">
          {{ task.dueLabel }}
        </span>
        <MemberAvatar :member="task.assignee" size="small" />
        <select
          :value="task.status.value"
          :style="{ '--status': STATUS_META[task.status.value].color }"
          aria-label="Change status"
          @click.stop
          @change="list.changeStatus(task, $event)"
        >
          <option v-for="option in STATUS_ORDER" :key="option" :value="option">
            {{ STATUS_META[option].label }}
          </option>
        </select>
      </button>

      <p v-if="!list.hasTasks(status)" class="ow-empty-row">No matching tasks</p>
    </section>
  </div>
</template>
ts
import { ref } from 'vue';
import { Reactive } from '../../ivue';
import type { Task } from './Task';
import type { TaskStatus } from './types';
import { Workspace } from './Workspace';

class $TaskBoard {
  get draggingTaskId() {
    return ref<string | null>(null);
  }

  get dropTarget() {
    return ref<TaskStatus | null>(null);
  }

  protected get $workspace() {
    return Workspace.Class.use();
  }

  tasksByStatus(status: TaskStatus) {
    return this.$workspace.tasksByStatus(status);
  }

  hasTasks(status: TaskStatus) {
    return this.tasksByStatus(status).length > 0;
  }

  isDropTarget(status: TaskStatus) {
    return this.dropTarget.value === status;
  }

  isDragging(task: Task.Model) {
    return this.draggingTaskId.value === task.id;
  }

  openTask(task: Task.Model) {
    this.$workspace.openTask(task.id);
  }

  startDrag(task: Task.Model) {
    this.draggingTaskId.value = task.id;
  }

  endDrag() {
    this.draggingTaskId.value = null;
    this.dropTarget.value = null;
  }

  enterDropTarget(status: TaskStatus) {
    this.dropTarget.value = status;
  }

  leaveDropTarget() {
    this.dropTarget.value = null;
  }

  drop(status: TaskStatus) {
    const task = this.$workspace.tasks.value.find(
      (candidate) => candidate.id === this.draggingTaskId.value
    );
    task?.setStatus(status);
    this.endDrag();
  }
}

export namespace TaskBoard {
  export const $Class = $TaskBoard;
  export let Class = Reactive($Class);
  export type Model = InstanceType<typeof Class>;
  export type Instance = typeof Class.Instance;
}
vue
<script setup lang="ts">
import MemberAvatar from './MemberAvatar.vue';
import { TaskBoard as TaskBoardModel } from './TaskBoard';
import { STATUS_META, STATUS_ORDER } from './types';

const board = new TaskBoardModel.Class();
</script>

<template>
  <div class="ow-board">
    <section
      v-for="status in STATUS_ORDER"
      :key="status"
      class="ow-board-column"
      :class="{ 'ow-board-column--target': board.isDropTarget(status) }"
      @dragover.prevent="board.enterDropTarget(status)"
      @dragleave="board.leaveDropTarget()"
      @drop.prevent="board.drop(status)"
    >
      <header>
        <span class="ow-status-dot" :style="{ background: STATUS_META[status].color }"></span>
        <strong>{{ STATUS_META[status].label }}</strong>
        <span>{{ board.tasksByStatus(status).length }}</span>
        <button type="button" aria-label="Column menu">
          <span class="ow-symbol">•••</span>
        </button>
      </header>

      <button
        v-for="task in board.tasksByStatus(status)"
        :key="task.id"
        type="button"
        class="ow-board-card"
        draggable="true"
        :class="{ 'ow-board-card--dragging': board.isDragging(task) }"
        @dragstart="board.startDrag(task)"
        @dragend="board.endDrag()"
        @click="board.openTask(task)"
      >
        <span class="ow-board-card__project">
          <i :style="{ background: task.project?.color }"></i>
          {{ task.project?.name.value }}
        </span>
        <strong>{{ task.title.value }}</strong>
        <span class="ow-board-card__tags">
          <em v-for="tag in task.tags.value.slice(0, 2)" :key="tag" :data-tag="tag">{{ tag }}</em>
        </span>
        <span v-if="task.checklist.value.length" class="ow-board-card__progress">
          <i><b :style="task.checklistBarStyle"></b></i>
          {{ task.completedChecklistCount }}/{{ task.checklist.value.length }}
        </span>
        <footer>
          <span
            class="ow-priority-flag"
            :style="{ color: task.priorityColor }"
            :title="task.priorityTitle"
          >
            ⚑
          </span>
          <span :class="{ overdue: task.isOverdue }">{{ task.dueLabel }}</span>
          <MemberAvatar :member="task.assignee" size="small" />
        </footer>
      </button>

      <div v-if="!board.hasTasks(status)" class="ow-board-empty">Drop a task here</div>
    </section>
  </div>
</template>
ts
import { ref } from 'vue';
import { Reactive } from '../../ivue';
import type { Task } from './Task';
import type { TaskPriority, TaskStatus } from './types';
import { Workspace } from './Workspace';

class $TaskDetails {
  constructor(readonly props: Readonly<TaskDetails.Props>) {}

  get comment() {
    return ref('');
  }

  protected get $workspace() {
    return Workspace.Class.use();
  }

  get task() {
    return this.props.task;
  }

  get members() {
    return this.$workspace.members.value;
  }

  get isCommentSubmitDisabled() {
    return !this.comment.value.trim();
  }

  memberById(memberId: string) {
    return this.$workspace.memberById(memberId);
  }

  close() {
    this.$workspace.closeTask();
  }

  updateTitle(event: Event) {
    this.task.title.value = (event.target as HTMLInputElement).value;
  }

  updateDescription(event: Event) {
    this.task.description.value = (event.target as HTMLTextAreaElement).value;
  }

  updateStatus(event: Event) {
    this.task.setStatus((event.target as HTMLSelectElement).value as TaskStatus);
  }

  updatePriority(event: Event) {
    this.task.setPriority((event.target as HTMLSelectElement).value as TaskPriority);
  }

  updateAssignee(event: Event) {
    this.task.setAssignee((event.target as HTMLSelectElement).value);
  }

  submitComment() {
    this.task.addComment(this.comment.value);
    this.comment.value = '';
  }
}

export namespace TaskDetails {
  export const $Class = $TaskDetails;
  export let Class = Reactive($Class);
  export type Model = InstanceType<typeof Class>;
  export type Instance = typeof Class.Instance;

  /* Types */

  export interface Props {
    task: Task.Model;
  }
}
vue
<script setup lang="ts">
import MemberAvatar from './MemberAvatar.vue';
import type { Task } from './Task';
import { TaskDetails as TaskDetailsModel } from './TaskDetails';
import { PRIORITY_META, STATUS_META, STATUS_ORDER } from './types';

const props = defineProps<{ task: Task.Model }>();
const details = new TaskDetailsModel.Class(props);
const { comment } = details;
</script>

<template>
  <div class="ow-detail-scrim" @click.self="details.close()">
    <aside class="ow-detail" aria-label="Task details">
      <header class="ow-detail__top">
        <span>
          <i :style="{ background: details.task.project?.color }"></i>
          {{ details.task.project?.name.value }} / {{ details.task.id }}
        </span>
        <div>
          <button type="button" title="Copy link">
            <span class="ow-symbol">↗</span>
          </button>
          <button type="button" aria-label="Close" @click="details.close()">
            <span class="ow-symbol">×</span>
          </button>
        </div>
      </header>

      <div class="ow-detail__body">
        <input
          class="ow-detail__title"
          :value="details.task.title.value"
          aria-label="Task title"
          @input="details.updateTitle($event)"
        />

        <div class="ow-detail__fields">
          <label>
            <span>Status</span>
            <select :value="details.task.status.value" @change="details.updateStatus($event)">
              <option v-for="status in STATUS_ORDER" :key="status" :value="status">
                {{ STATUS_META[status].label }}
              </option>
            </select>
          </label>
          <label>
            <span>Assignee</span>
            <select :value="details.task.assigneeId.value" @change="details.updateAssignee($event)">
              <option v-for="member in details.members" :key="member.id" :value="member.id">
                {{ member.name }}
              </option>
            </select>
          </label>
          <label>
            <span>Priority</span>
            <select :value="details.task.priority.value" @change="details.updatePriority($event)">
              <option v-for="(meta, priority) in PRIORITY_META" :key="priority" :value="priority">
                {{ meta.label }}
              </option>
            </select>
          </label>
          <label>
            <span>Due date</span>
            <input v-model="details.task.dueDate.value" type="date" />
          </label>
        </div>

        <section class="ow-detail-section">
          <h3>Description</h3>
          <textarea
            :value="details.task.description.value"
            rows="4"
            @input="details.updateDescription($event)"
          ></textarea>
        </section>

        <section v-if="details.task.checklist.value.length" class="ow-detail-section">
          <header>
            <h3>Checklist</h3>
            <span>{{ details.task.checklistProgress }}%</span>
          </header>
          <div class="ow-checklist-bar">
            <i :style="details.task.checklistBarStyle"></i>
          </div>
          <label
            v-for="item in details.task.checklist.value"
            :key="item.id"
            class="ow-checklist-item"
            :class="{ done: item.done }"
          >
            <input
              type="checkbox"
              :checked="item.done"
              @change="details.task.toggleChecklist(item.id)"
            />
            <span>{{ item.label }}</span>
          </label>
        </section>

        <section class="ow-detail-section ow-comments">
          <h3>
            Comments <span>{{ details.task.comments.value.length }}</span>
          </h3>
          <article v-for="entry in details.task.comments.value" :key="entry.id">
            <MemberAvatar :member="details.memberById(entry.authorId)" size="small" />
            <div>
              <strong>{{ details.memberById(entry.authorId)?.name }}</strong>
              <small>{{ entry.createdAt }}</small>
              <p>{{ entry.body }}</p>
            </div>
          </article>
          <form class="ow-comment-form" @submit.prevent="details.submitComment()">
            <MemberAvatar :member="details.memberById('you')" size="small" />
            <input v-model="comment" placeholder="Write a comment…" />
            <button type="submit" :disabled="details.isCommentSubmitDisabled">Send</button>
          </form>
        </section>
      </div>
    </aside>
  </div>
</template>

Released under the MIT License.