OverviewFilesCommitsIssuesDocsReleasesPeers

hypercore-kb / hyper-social / hyper-board / lib / board-store.js

200 lines · javascript ref:
import { randomBytes } from 'crypto'

const DEFAULT_LANES = ['backlog', 'todo', 'in-progress', 'done']

class BoardStore {
  constructor (bee) {
    this.bee = bee
  }

  _cardKey (id) {
    return `card/${id}`
  }

  _laneKey (name) {
    return `lane/${name}`
  }

  _genId () {
    return randomBytes(8).toString('hex')
  }

  async ready () {
    await this.bee.ready()

    // Initialize default lanes if none exist
    const lanes = await this.listLanes()
    if (lanes.length === 0) {
      for (let i = 0; i < DEFAULT_LANES.length; i++) {
        await this.bee.put(this._laneKey(DEFAULT_LANES[i]), {
          name: DEFAULT_LANES[i],
          order: i,
          created: Date.now()
        })
      }
    }
  }

  async createCard (opts = {}) {
    const id = opts.id || this._genId()
    const now = Date.now()

    const card = {
      id,
      title: opts.title || 'Untitled',
      description: opts.description || '',
      lane: opts.lane || 'backlog',
      assignee: opts.assignee || '',
      tags: opts.tags || [],
      priority: opts.priority || 'normal', // low, normal, high, urgent
      created: now,
      updated: now
    }

    await this.bee.put(this._cardKey(id), card)
    return card
  }

  async getCard (id) {
    const node = await this.bee.get(this._cardKey(id))
    return node ? node.value : null
  }

  async updateCard (id, updates = {}) {
    const existing = await this.getCard(id)
    if (!existing) throw new Error(`Card not found: ${id}`)

    const card = {
      ...existing,
      ...updates,
      id, // never overwrite id
      created: existing.created, // never overwrite created
      updated: Date.now()
    }

    await this.bee.put(this._cardKey(id), card)
    return card
  }

  async deleteCard (id) {
    const existing = await this.getCard(id)
    if (!existing) return false

    await this.bee.del(this._cardKey(id))
    return true
  }

  async moveCard (id, lane) {
    return this.updateCard(id, { lane })
  }

  async listCards (opts = {}) {
    const cards = []

    for await (const node of this.bee.createReadStream({
      gte: 'card/',
      lte: 'card/~'
    })) {
      const card = node.value
      if (!card) continue

      // Filter by lane
      if (opts.lane && card.lane !== opts.lane) continue

      // Filter by assignee
      if (opts.assignee && card.assignee !== opts.assignee) continue

      // Filter by tag
      if (opts.tag && !card.tags?.includes(opts.tag)) continue

      // Filter by priority
      if (opts.priority && card.priority !== opts.priority) continue

      cards.push(card)
    }

    // Sort by priority then created date
    const priorityOrder = { urgent: 0, high: 1, normal: 2, low: 3 }
    cards.sort((a, b) => {
      const pa = priorityOrder[a.priority] ?? 2
      const pb = priorityOrder[b.priority] ?? 2
      if (pa !== pb) return pa - pb
      return a.created - b.created
    })

    return cards
  }

  async addLane (name, order) {
    const existing = await this.bee.get(this._laneKey(name))
    if (existing) throw new Error(`Lane already exists: ${name}`)

    if (order === undefined) {
      const lanes = await this.listLanes()
      order = lanes.length
    }

    const lane = { name, order, created: Date.now() }
    await this.bee.put(this._laneKey(name), lane)
    return lane
  }

  async removeLane (name) {
    // Don't allow removing lanes that have cards
    const cards = await this.listCards({ lane: name })
    if (cards.length > 0) {
      throw new Error(`Cannot remove lane "${name}": ${cards.length} cards still in it`)
    }

    await this.bee.del(this._laneKey(name))
    return true
  }

  async listLanes () {
    const lanes = []

    for await (const node of this.bee.createReadStream({
      gte: 'lane/',
      lte: 'lane/~'
    })) {
      if (node.value) lanes.push(node.value)
    }

    lanes.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
    return lanes
  }

  async search (query) {
    const q = query.toLowerCase()
    const cards = await this.listCards()

    return cards.filter(card => {
      if (card.title.toLowerCase().includes(q)) return true
      if (card.description.toLowerCase().includes(q)) return true
      if (card.tags?.some(t => t.toLowerCase().includes(q))) return true
      return false
    })
  }

  async stats () {
    const cards = await this.listCards()
    const lanes = await this.listLanes()

    const byLane = {}
    for (const lane of lanes) {
      byLane[lane.name] = 0
    }
    for (const card of cards) {
      byLane[card.lane] = (byLane[card.lane] || 0) + 1
    }

    return {
      totalCards: cards.length,
      totalLanes: lanes.length,
      byLane
    }
  }
}

export { BoardStore, DEFAULT_LANES }