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: TaskOwner,
    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 { Member } from './Member';
import { Project } from './Project';
import { Task } from './Task';
import { activitySeeds, memberSeeds, projectSeeds, taskSeeds } from './seed';
import {
  STATUS_ORDER,
  type ActivityEntry,
  type TaskPriority,
  type TaskStatus,
  type WorkspaceView,
} from './types';

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

class $Workspace {
  constructor() {
    this.reset();
  }

  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 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 filterCount() {
    return (
      Number(this.priorityFilter.value !== 'all') +
      Number(this.assigneeFilter.value !== 'all') +
      Number(Boolean(this.search.value.trim()))
    );
  }

  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),
    );
  }

  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'],
      },
      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 = projectSeeds.map((seed) => new Project.Class(seed));
    this.members.value = memberSeeds.map((seed) => new Member.Class(seed));
    this.tasks.value = taskSeeds.map(
      (seed) => new Task.Class(this, seed, dateAtOffset(seed.dueOffset)),
    );
    this.activities.value = activitySeeds.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;
  }

  statusOrder = STATUS_ORDER;
}

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

let workspace: Workspace.Model | undefined;

export function useWorkspace() {
  return (workspace ??= new Workspace.Class());
}
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';

export interface TaskOwner {
  memberById(memberId: string): Member.Model | undefined;
  projectById(projectId: string): Project.Model | undefined;
  recordActivity(actorId: string, icon: string, text: string): void;
}

class $Task {
  readonly id: string;
  readonly projectId: string;
  readonly workspace: TaskOwner;

  constructor(workspace: TaskOwner, 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,
    }));
  }

  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 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;
}
ts
import { ref } from 'vue';
import { Reactive } from '../../ivue';
import type { MemberSeed } from './types';

class $Member {
  readonly id: string;
  readonly name: string;
  readonly initials: string;
  readonly role: string;
  readonly color: string;

  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;
  }

  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 {
  readonly id: string;
  readonly icon: string;
  readonly color: string;

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

  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 { useWorkspace } from './Workspace';

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

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

  private get $workspace() {
    return useWorkspace();
  }

  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, selectedProjectId, view } =
  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: selectedProjectId === 'all' }"
            @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: selectedProjectId === project.id }"
            @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.workloadPercent(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.selectedProject?.color ?? '#6366f1',
              }"
            >
              {{ workspace.selectedProject?.icon ?? '◎' }}
            </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.completionRate}%` }"></b></i>
            </article>
            <article>
              <span>Active</span><strong>{{ workspace.activeCount }}</strong
              ><small>in flight</small>
            </article>
            <article :class="{ danger: workspace.overdueCount > 0 }">
              <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: view === 'list' }"
              @click="view = 'list'"
            >
              <span class="ow-symbol">☷</span> List
            </button>
            <button
              type="button"
              :class="{ active: view === 'board' }"
              @click="view = 'board'"
            >
              <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="view === 'list'" />
            <TaskBoard v-else />
            <div
              v-if="workspace.filteredTasks.length === 0"
              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 { useWorkspace } from './Workspace';

class $TaskList {
  private get $workspace() {
    return useWorkspace();
  }

  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 { useWorkspace } from './Workspace';

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

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

  private get $workspace() {
    return useWorkspace();
  }

  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 { PRIORITY_META, 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="{ width: `${task.checklistProgress}%` }"></b></i>
          {{ task.completedChecklistCount }}/{{ task.checklist.value.length }}
        </span>
        <footer>
          <span
            class="ow-priority-flag"
            :style="{ color: PRIORITY_META[task.priority.value].color }"
            :title="`${PRIORITY_META[task.priority.value].label} priority`"
          >

          </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 { useWorkspace } from './Workspace';

export interface TaskDetailsProps {
  task: Task.Model;
}

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

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

  private get $workspace() {
    return useWorkspace();
  }

  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;
}
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="{ width: `${details.task.checklistProgress}%` }"></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.