Merge pull request 'Add mobile-first kanban board view for Chorus triage' (#3) from feat/chorus-triage-views into main

This commit is contained in:
pyr0ball 2026-07-15 20:14:53 -07:00
commit d23c412cf4
17 changed files with 763 additions and 34 deletions

View file

@ -22,7 +22,13 @@ class ManualShareCog(commands.Cog):
if modality is None:
return
attachment_urls = [attachment.url for attachment in message.attachments]
raw_content = message.content
if attachment_urls:
attachments_block = "Attachments:\n" + "\n".join(attachment_urls)
raw_content = f"{raw_content}\n\n{attachments_block}" if raw_content else attachments_block
display_content = message.content or "(attachment only - see original message)"
snippet = display_content if len(display_content) <= 120 else display_content[:117] + "..."
@ -33,6 +39,7 @@ class ManualShareCog(commands.Cog):
raw_content=raw_content,
captured_at=message.created_at,
direction=direction_for_modality(modality),
image_url=attachment_urls[0] if attachment_urls else None,
)
inbox = self.bot.get_channel(self.config.inbox_channel_id)

View file

@ -25,7 +25,7 @@ class ThreadIngestCog(commands.Cog):
return
parsed = parse_ping_embed(parent_message.embeds[0])
await self.backend_client.create_item(
item = await self.backend_client.create_item(
modality=parsed["modality"],
raw_content=parsed["raw_content"],
captured_at=parsed["captured_at"],
@ -33,8 +33,9 @@ class ThreadIngestCog(commands.Cog):
sender_id=parsed["sender_id"],
direction=parsed["direction"],
)
item_url = f"{self.config.web_base_url}/?item={item['id']}"
await thread.send(
"Logged in Chorus - open the app to fill in details and track this one."
f"Logged in Chorus - {item_url} to fill in details and track this one."
)

View file

@ -9,6 +9,7 @@ class Config:
inbox_channel_id: int
triage_role_id: int
backend_base_url: str
web_base_url: str
google_voice_imap_host: str
google_voice_imap_user: str
google_voice_imap_password: str
@ -33,6 +34,7 @@ def load_config() -> Config:
inbox_channel_id=int(env("CHORUS_INBOX_CHANNEL_ID")),
triage_role_id=int(env("CHORUS_TRIAGE_ROLE_ID")),
backend_base_url=env("CHORUS_BACKEND_BASE_URL"),
web_base_url=os.environ.get("CHORUS_WEB_BASE_URL", "https://chorus.circuitforge.tech"),
google_voice_imap_host=env("CHORUS_GVOICE_IMAP_HOST"),
google_voice_imap_user=env("CHORUS_GVOICE_IMAP_USER"),
google_voice_imap_password=env("CHORUS_GVOICE_IMAP_PASSWORD"),

View file

@ -36,7 +36,7 @@ def _safe_field_value(value: str) -> str:
def build_ping_embed(
*, modality: str, sender_id: str | None, snippet: str, raw_content: str,
captured_at: datetime, direction: str = "inbound",
captured_at: datetime, direction: str = "inbound", image_url: str | None = None,
) -> discord.Embed:
label = MODALITY_LABELS.get(modality, modality)
embed = discord.Embed(
@ -52,6 +52,11 @@ def build_ping_embed(
embed.add_field(
name="raw_content", value=_safe_field_value(raw_content), inline=False,
)
# Discord CDN attachment URLs carry a signed expiry (ex/is/hm query params),
# typically valid ~24h. Fine for same-day triage; a stale link just stops
# rendering the preview without breaking anything else in the embed.
if image_url:
embed.set_image(url=image_url)
return embed

View file

@ -10,12 +10,20 @@ class _FakeConfig:
triage_role_id = 222
def _make_message(*, channel_name, content="hello", author_bot=False, channel_id=999):
def _make_attachment(url):
attachment = MagicMock(spec=discord.Attachment)
attachment.url = url
return attachment
def _make_message(*, channel_name, content="hello", author_bot=False, channel_id=999,
attachments=None):
message = MagicMock(spec=discord.Message)
message.author.bot = author_bot
message.channel.id = channel_id
message.channel.name = channel_name
message.content = content
message.attachments = attachments or []
message.created_at = __import__("datetime").datetime(2026, 7, 13, 7, 0, 0)
return message
@ -49,3 +57,42 @@ async def test_existing_modality_channel_still_forwards_with_inbound_direction()
sent_embed = inbox.send.call_args.kwargs["embed"]
parsed = parse_ping_embed(sent_embed)
assert parsed["direction"] == "inbound"
@pytest.mark.asyncio
async def test_message_with_attachment_includes_image_and_url_in_raw_content():
bot = MagicMock()
inbox = AsyncMock()
bot.get_channel.return_value = inbox
cog = ManualShareCog(bot, _FakeConfig())
attachment = _make_attachment("https://cdn.discordapp.com/attachments/1/2/screenshot.png")
message = _make_message(
channel_name="leads-found", content="Found on Nextdoor", attachments=[attachment],
)
await cog.on_message(message)
sent_embed = inbox.send.call_args.kwargs["embed"]
assert sent_embed.image.url == "https://cdn.discordapp.com/attachments/1/2/screenshot.png"
parsed = parse_ping_embed(sent_embed)
assert "https://cdn.discordapp.com/attachments/1/2/screenshot.png" in parsed["raw_content"]
assert "Found on Nextdoor" in parsed["raw_content"]
@pytest.mark.asyncio
async def test_attachment_only_message_still_includes_image():
bot = MagicMock()
inbox = AsyncMock()
bot.get_channel.return_value = inbox
cog = ManualShareCog(bot, _FakeConfig())
attachment = _make_attachment("https://cdn.discordapp.com/attachments/1/2/screenshot.png")
message = _make_message(channel_name="leads-found", content="", attachments=[attachment])
await cog.on_message(message)
sent_embed = inbox.send.call_args.kwargs["embed"]
assert sent_embed.image.url == "https://cdn.discordapp.com/attachments/1/2/screenshot.png"
parsed = parse_ping_embed(sent_embed)
assert "https://cdn.discordapp.com/attachments/1/2/screenshot.png" in parsed["raw_content"]

View file

@ -124,3 +124,21 @@ def test_parse_ping_embed_defaults_direction_when_field_missing():
parsed = ping.parse_ping_embed(embed)
assert parsed["direction"] == "inbound"
def test_build_ping_embed_sets_image_when_url_given():
embed = ping.build_ping_embed(
modality="lead_found", sender_id=None, snippet="found post",
raw_content="Found on Nextdoor",
captured_at=datetime(2026, 7, 13, 9, 0, 0, tzinfo=timezone.utc),
image_url="https://cdn.discordapp.com/attachments/1/2/screenshot.png",
)
assert embed.image.url == "https://cdn.discordapp.com/attachments/1/2/screenshot.png"
def test_build_ping_embed_no_image_by_default():
embed = ping.build_ping_embed(
modality="bh_email", sender_id="jane@example.com", snippet="hi",
raw_content="hi", captured_at=datetime(2026, 7, 13, 9, 0, 0, tzinfo=timezone.utc),
)
assert embed.image.url is None

View file

@ -7,12 +7,26 @@ from chorus_bot.ping import build_ping_embed
class _FakeConfig:
inbox_channel_id = 111
web_base_url = "https://chorus.circuitforge.tech"
def _make_thread(embed):
parent_message = MagicMock()
parent_message.embeds = [embed]
thread = MagicMock(spec=discord.Thread)
thread.id = 555
thread.parent_id = 111
thread.parent.fetch_message = AsyncMock(return_value=parent_message)
thread.send = AsyncMock()
return thread
@pytest.mark.asyncio
async def test_thread_create_passes_direction_to_backend_client():
bot = MagicMock()
backend_client = AsyncMock()
backend_client.create_item.return_value = {"id": 42}
cog = ThreadIngestCog(bot, _FakeConfig(), backend_client)
embed = build_ping_embed(
@ -22,16 +36,32 @@ async def test_thread_create_passes_direction_to_backend_client():
tzinfo=__import__("datetime").timezone.utc),
direction="outbound",
)
parent_message = MagicMock()
parent_message.embeds = [embed]
thread = MagicMock(spec=discord.Thread)
thread.id = 555
thread.parent_id = 111
thread.parent.fetch_message = AsyncMock(return_value=parent_message)
thread.send = AsyncMock()
thread = _make_thread(embed)
await cog.on_thread_create(thread)
backend_client.create_item.assert_awaited_once()
assert backend_client.create_item.call_args.kwargs["direction"] == "outbound"
@pytest.mark.asyncio
async def test_thread_create_reply_includes_item_link():
bot = MagicMock()
backend_client = AsyncMock()
backend_client.create_item.return_value = {"id": 42}
cog = ThreadIngestCog(bot, _FakeConfig(), backend_client)
embed = build_ping_embed(
modality="lead_found", sender_id=None, snippet="found post",
raw_content="Someone posting about book donation",
captured_at=__import__("datetime").datetime(2026, 7, 13, 7, 0, 0,
tzinfo=__import__("datetime").timezone.utc),
direction="outbound",
)
thread = _make_thread(embed)
await cog.on_thread_create(thread)
thread.send.assert_awaited_once()
reply_text = thread.send.call_args.args[0]
assert "https://chorus.circuitforge.tech/?item=42" in reply_text

View file

@ -7,10 +7,15 @@ services:
- chorus_data:/data
expose:
- "8000"
networks:
- default
- caddy-internal
bot:
build: ./bot
env_file: .env
environment:
PYTHONUNBUFFERED: "1"
depends_on:
- backend
volumes:
@ -20,7 +25,16 @@ services:
build: ./frontend
expose:
- "80"
networks:
- default
- caddy-internal
volumes:
chorus_data:
chorus_bot_state:
networks:
default:
caddy-internal:
name: caddy-proxy_caddy-internal
external: true

View file

@ -1,6 +1,7 @@
<script setup>
import { ref, onMounted } from 'vue'
import { ref, onMounted, watch } from 'vue'
import TriageList from './components/TriageList.vue'
import KanbanBoard from './components/KanbanBoard.vue'
import ItemModal from './components/ItemModal.vue'
import { fetchItems, fetchItem } from './api.js'
@ -9,6 +10,14 @@ const selectedItem = ref(null)
const includeDone = ref(false)
const errorMessage = ref('')
const VIEW_MODE_KEY = 'chorus_view_mode'
const storedViewMode = localStorage.getItem(VIEW_MODE_KEY)
const viewMode = ref(storedViewMode === 'board' ? 'board' : 'list')
watch(viewMode, (mode) => {
localStorage.setItem(VIEW_MODE_KEY, mode)
})
async function loadItems() {
try {
errorMessage.value = ''
@ -36,17 +45,44 @@ async function onSaved() {
await loadItems()
}
onMounted(loadItems)
onMounted(async () => {
await loadItems()
const itemId = new URLSearchParams(window.location.search).get('item')
if (itemId) {
await openItem(itemId)
}
})
</script>
<template>
<div class="container">
<h1>Chorus</h1>
<label class="include-done-toggle">
<input type="checkbox" v-model="includeDone" @change="loadItems" />
Show completed
</label>
<div class="view-controls">
<label class="include-done-toggle">
<input type="checkbox" v-model="includeDone" @change="loadItems" />
Show completed
</label>
<div class="view-toggle">
<button
type="button"
:aria-pressed="viewMode === 'list'"
:class="{ active: viewMode === 'list' }"
@click="viewMode = 'list'"
>
List
</button>
<button
type="button"
:aria-pressed="viewMode === 'board'"
:class="{ active: viewMode === 'board' }"
@click="viewMode = 'board'"
>
Board
</button>
</div>
</div>
<p v-if="errorMessage" class="status-message">{{ errorMessage }}</p>
@ -54,22 +90,52 @@ onMounted(loadItems)
Nothing to triage right now.
</p>
<TriageList v-else :items="items" @select-item="openItem" />
<TriageList v-else-if="viewMode === 'list'" :items="items" @select-item="openItem" />
<KanbanBoard v-else :items="items" @select-item="openItem" />
<ItemModal :item="selectedItem" @close="closeModal" @saved="onSaved" />
</div>
</template>
<style scoped>
.view-controls {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-md);
}
.include-done-toggle {
display: flex;
align-items: center;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-md);
color: var(--color-text-muted);
font-size: 0.9rem;
}
.view-toggle {
display: flex;
gap: var(--spacing-sm);
}
.view-toggle button {
min-height: 44px;
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
cursor: pointer;
}
.view-toggle button.active {
background: var(--color-accent);
color: white;
border-color: var(--color-accent);
}
.status-message {
color: var(--color-text-muted);
padding: var(--spacing-md);

View file

@ -1,6 +1,7 @@
<script setup>
import { ref, watch } from 'vue'
import { updateItem } from '../api.js'
import { DONATION_STAGES, OTHER_STAGES, OUTBOUND_STAGES, UNSORTED_STAGES, STAGE_LABELS } from '../stages.js'
const props = defineProps({ item: { type: Object, default: null } })
const emit = defineEmits(['close', 'saved'])
@ -11,13 +12,6 @@ const stage = ref('')
const notes = ref('')
const followUpDate = ref('')
const DONATION_STAGES = ['new', 'replied', 'pickup_scheduled', 'picked_up', 'logged_in_bookmark']
const OTHER_STAGES = ['new', 'done']
const OUTBOUND_STAGES = [
'new', 'reviewed', 'outreach_drafted', 'outreach_sent',
'replied', 'pickup_scheduled', 'picked_up', 'logged_in_bookmark',
]
watch(() => props.item, (item) => {
if (!item) return
senderId.value = item.sender_id || ''
@ -31,7 +25,7 @@ function stagesForType() {
if (props.item?.direction === 'outbound') return OUTBOUND_STAGES
if (type.value === 'donation') return DONATION_STAGES
if (type.value === 'other') return OTHER_STAGES
return ['new']
return UNSORTED_STAGES
}
watch(type, () => {
@ -71,7 +65,7 @@ async function save() {
<label>Stage
<select v-model="stage">
<option v-for="s in stagesForType()" :key="s" :value="s">{{ s }}</option>
<option v-for="s in stagesForType()" :key="s" :value="s">{{ STAGE_LABELS[s] || s }}</option>
</select>
</label>

View file

@ -0,0 +1,304 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import ModalityBadge from './ModalityBadge.vue'
import { DONATION_STAGES, OTHER_STAGES, OUTBOUND_STAGES, UNSORTED_STAGES, STAGE_LABELS } from '../stages.js'
const props = defineProps({ items: { type: Array, required: true } })
const emit = defineEmits(['select-item'])
const BOARDS = [
{ key: 'donation', label: 'Donations', stages: DONATION_STAGES },
{ key: 'other', label: 'Other', stages: OTHER_STAGES },
{ key: 'outbound', label: 'Outbound Leads', stages: OUTBOUND_STAGES },
{ key: 'unsorted', label: 'Unsorted', stages: UNSORTED_STAGES },
]
function bucketFor(item) {
if (item.direction === 'outbound') return 'outbound'
if (item.type === 'donation') return 'donation'
if (item.type === 'other') return 'other'
return 'unsorted'
}
const activeBoardKey = ref(BOARDS[0].key)
const activeStage = ref(BOARDS[0].stages[0])
const activeBoard = computed(() => BOARDS.find((b) => b.key === activeBoardKey.value))
watch(activeBoardKey, (key) => {
activeStage.value = BOARDS.find((b) => b.key === key).stages[0]
})
const bucketedItems = computed(() => {
const grouped = {}
for (const board of BOARDS) grouped[board.key] = []
for (const item of props.items) {
grouped[bucketFor(item)].push(item)
}
return grouped
})
function itemsForStage(boardKey, stage) {
return bucketedItems.value[boardKey].filter((i) => i.stage === stage)
}
function boardCount(boardKey) {
return bucketedItems.value[boardKey].length
}
function selectBoard(key) {
activeBoardKey.value = key
}
const tabRefs = ref([])
function setTabRef(el, index) {
tabRefs.value[index] = el
}
async function handleTabKeydown(event, index) {
let nextIndex = null
if (event.key === 'ArrowRight') {
nextIndex = (index + 1) % BOARDS.length
} else if (event.key === 'ArrowLeft') {
nextIndex = (index - 1 + BOARDS.length) % BOARDS.length
} else if (event.key === 'Home') {
nextIndex = 0
} else if (event.key === 'End') {
nextIndex = BOARDS.length - 1
} else {
return
}
event.preventDefault()
selectBoard(BOARDS[nextIndex].key)
await nextTick()
tabRefs.value[nextIndex]?.focus()
}
function selectStage(stage) {
activeStage.value = stage
}
function labelFor(stage) {
return STAGE_LABELS[stage] || stage
}
</script>
<template>
<div class="kanban-board">
<div class="board-tabs" role="tablist" aria-label="Item category">
<button
v-for="(board, index) in BOARDS"
:key="board.key"
:ref="el => setTabRef(el, index)"
role="tab"
:id="`board-tab-${board.key}`"
:aria-selected="board.key === activeBoardKey"
:aria-controls="`board-panel-${board.key}`"
:tabindex="board.key === activeBoardKey ? 0 : -1"
type="button"
class="board-tab"
:class="{ active: board.key === activeBoardKey }"
@click="selectBoard(board.key)"
@keydown="handleTabKeydown($event, index)"
>
{{ board.label }} ({{ boardCount(board.key) }})
</button>
</div>
<div class="mobile-breadcrumb">
{{ activeBoard.label }} &rsaquo; {{ labelFor(activeStage) }}
</div>
<div class="mobile-stage-strip">
<button
v-for="stage in activeBoard.stages"
:key="stage"
type="button"
:aria-pressed="stage === activeStage"
class="stage-strip-button"
:class="{ active: stage === activeStage }"
@click="selectStage(stage)"
>
{{ labelFor(stage) }} ({{ itemsForStage(activeBoardKey, stage).length }})
</button>
</div>
<div
v-for="board in BOARDS"
:key="board.key"
v-show="board.key === activeBoardKey"
role="tabpanel"
:id="`board-panel-${board.key}`"
:aria-labelledby="`board-tab-${board.key}`"
class="columns"
>
<div
v-for="stage in board.stages"
:key="stage"
class="column"
:class="{ 'is-active-mobile': stage === activeStage && board.key === activeBoardKey }"
>
<h3>{{ labelFor(stage) }} ({{ itemsForStage(board.key, stage).length }})</h3>
<button
v-for="item in itemsForStage(board.key, stage)"
:key="item.id"
type="button"
class="card"
@click="emit('select-item', item.id)"
>
<ModalityBadge :modality="item.modality" />
<p class="card-sender">{{ item.sender_id || 'not set' }}</p>
<p class="card-snippet">{{ item.raw_content.slice(0, 80) }}</p>
<p v-if="item.follow_up_date" class="card-followup">Follow up: {{ item.follow_up_date }}</p>
</button>
<p v-if="itemsForStage(board.key, stage).length === 0" class="column-empty">Nothing here</p>
</div>
</div>
</div>
</template>
<style scoped>
.kanban-board {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.board-tabs {
display: flex;
gap: var(--spacing-sm);
flex-wrap: wrap;
}
.board-tab {
min-height: 44px;
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
cursor: pointer;
}
.board-tab.active {
background: var(--color-accent);
color: white;
border-color: var(--color-accent);
}
.mobile-breadcrumb {
display: none;
color: var(--color-text-muted);
font-size: 0.9rem;
}
.mobile-stage-strip {
display: none;
gap: var(--spacing-sm);
overflow-x: auto;
}
.stage-strip-button {
min-height: 44px;
min-width: 44px;
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
white-space: nowrap;
cursor: pointer;
}
.stage-strip-button.active {
background: var(--color-accent);
color: white;
border-color: var(--color-accent);
}
.columns {
display: flex;
gap: var(--spacing-md);
overflow-x: auto;
}
.column {
flex: 1;
min-width: 220px;
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: var(--spacing-sm);
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.column h3 {
margin: 0;
font-size: 1rem;
}
.card {
min-height: 44px;
display: block;
width: 100%;
text-align: left;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-bg);
color: var(--color-text);
padding: var(--spacing-sm);
cursor: pointer;
}
.card:hover {
background: var(--color-surface);
}
.card-sender {
font-weight: 600;
margin: 0.25rem 0;
}
.card-snippet {
margin: 0.25rem 0;
color: var(--color-text-muted);
font-size: 0.9rem;
}
.card-followup {
margin: 0.25rem 0 0;
font-size: 0.8rem;
color: var(--color-text-muted);
}
.column-empty {
color: var(--color-text-muted);
font-style: italic;
font-size: 0.9rem;
}
@media (max-width: 640px) {
.mobile-breadcrumb {
display: block;
}
.mobile-stage-strip {
display: flex;
}
.columns {
overflow-x: visible;
}
.column {
display: none;
min-width: 0;
}
.column.is-active-mobile {
display: flex;
}
}
</style>

19
frontend/src/stages.js Normal file
View file

@ -0,0 +1,19 @@
export const DONATION_STAGES = ['new', 'replied', 'pickup_scheduled', 'picked_up', 'logged_in_bookmark']
export const OTHER_STAGES = ['new', 'done']
export const OUTBOUND_STAGES = [
'new', 'reviewed', 'outreach_drafted', 'outreach_sent',
'replied', 'pickup_scheduled', 'picked_up', 'logged_in_bookmark',
]
export const UNSORTED_STAGES = ['new']
export const STAGE_LABELS = {
new: 'New',
replied: 'Replied',
pickup_scheduled: 'Pickup Scheduled',
picked_up: 'Picked Up',
logged_in_bookmark: 'Logged in Bookmark',
done: 'Done',
reviewed: 'Reviewed',
outreach_drafted: 'Outreach Drafted',
outreach_sent: 'Outreach Sent',
}

View file

@ -3,16 +3,16 @@
--color-surface: #f4f4f6;
--color-text: #1a1a1e;
--color-text-muted: #5b5b63;
--color-border: #d8d8dd;
--color-border: #8c8c91;
--color-accent: #4a6fa5;
--modality-bh_email: #4a6fa5;
--modality-personal_email: #6f9c8f;
--modality-bh_text: #a5764a;
--modality-bh_text: #8c643f;
--modality-personal_text: #a58a4a;
--modality-voice: #8a6fa5;
--modality-fb_messenger: #4a6fa5;
--modality-nd_messenger: #6f8fa5;
--modality-fb_messenger: #3b5998;
--modality-nd_messenger: #597186;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
@ -26,7 +26,7 @@
--color-surface: #202024;
--color-text: #e8e8ea;
--color-text-muted: #a0a0a8;
--color-border: #33333a;
--color-border: #64646e;
}
}

View file

@ -0,0 +1,54 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import App from '../src/App.vue'
vi.mock('../src/api.js', () => ({
fetchItems: vi.fn(async () => []),
fetchItem: vi.fn(async (id) => ({ id })),
}))
beforeEach(() => {
localStorage.clear()
})
describe('App view mode', () => {
it('defaults to list view when nothing is stored', async () => {
const wrapper = mount(App)
await flushPromises()
const listButton = wrapper.findAll('.view-toggle button').find((b) => b.text() === 'List')
expect(listButton.classes()).toContain('active')
})
it('honors a stored board view mode on mount', async () => {
localStorage.setItem('chorus_view_mode', 'board')
const wrapper = mount(App)
await flushPromises()
const boardButton = wrapper.findAll('.view-toggle button').find((b) => b.text() === 'Board')
expect(boardButton.classes()).toContain('active')
})
it('defaults to list view for an invalid stored value', async () => {
localStorage.setItem('chorus_view_mode', 'garbage')
const wrapper = mount(App)
await flushPromises()
const listButton = wrapper.findAll('.view-toggle button').find((b) => b.text() === 'List')
expect(listButton.classes()).toContain('active')
})
it('clicking Board writes the choice to localStorage', async () => {
const wrapper = mount(App)
await flushPromises()
const boardButton = wrapper.findAll('.view-toggle button').find((b) => b.text() === 'Board')
await boardButton.trigger('click')
expect(localStorage.getItem('chorus_view_mode')).toBe('board')
})
it('sets aria-pressed correctly on the view toggle buttons', async () => {
const wrapper = mount(App)
await flushPromises()
const listButton = wrapper.findAll('.view-toggle button').find((b) => b.text() === 'List')
const boardButton = wrapper.findAll('.view-toggle button').find((b) => b.text() === 'Board')
expect(listButton.attributes('aria-pressed')).toBe('true')
expect(boardButton.attributes('aria-pressed')).toBe('false')
})
})

View file

@ -0,0 +1,107 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import KanbanBoard from '../src/components/KanbanBoard.vue'
function makeItem(overrides = {}) {
return {
id: 1,
modality: 'bh_email',
sender_id: 'jane@example.com',
raw_content: 'I have some books to donate.',
type: null,
stage: 'new',
direction: 'inbound',
follow_up_date: '',
...overrides,
}
}
describe('KanbanBoard', () => {
it('buckets items into the correct board and shows counts on tabs', () => {
const items = [
makeItem({ id: 1, type: 'donation', direction: 'inbound', stage: 'new' }),
makeItem({ id: 2, type: 'other', direction: 'inbound', stage: 'new' }),
makeItem({ id: 3, type: null, direction: 'outbound', stage: 'new' }),
makeItem({ id: 4, type: null, direction: 'inbound', stage: 'new' }),
]
const wrapper = mount(KanbanBoard, { props: { items } })
const tabs = wrapper.findAll('[role="tab"]').map((t) => t.text())
expect(tabs).toEqual([
'Donations (1)', 'Other (1)', 'Outbound Leads (1)', 'Unsorted (1)',
])
})
it('defaults to the Donations board and its first stage', () => {
const wrapper = mount(KanbanBoard, { props: { items: [] } })
const activeTab = wrapper.find('[role="tab"][aria-selected="true"]')
expect(activeTab.text()).toContain('Donations')
})
it('shows humanized stage labels, not raw stage strings, on column headers', () => {
const items = [makeItem({ id: 1, type: 'donation', stage: 'pickup_scheduled' })]
const wrapper = mount(KanbanBoard, { props: { items } })
expect(wrapper.text()).toContain('Pickup Scheduled')
expect(wrapper.text()).not.toContain('pickup_scheduled')
})
it('switching board tabs resets the active stage to that board\'s first stage', async () => {
const wrapper = mount(KanbanBoard, { props: { items: [] } })
const outboundTab = wrapper.findAll('[role="tab"]').find((t) => t.text().includes('Outbound Leads'))
await outboundTab.trigger('click')
const breadcrumb = wrapper.find('.mobile-breadcrumb')
expect(breadcrumb.text()).toContain('New')
expect(breadcrumb.text()).toContain('Outbound Leads')
})
it('clicking a stage-strip button switches the active stage', async () => {
const items = [makeItem({ id: 1, type: 'donation', stage: 'replied' })]
const wrapper = mount(KanbanBoard, { props: { items } })
const repliedButton = wrapper.findAll('.stage-strip-button').find((b) => b.text().includes('Replied'))
await repliedButton.trigger('click')
expect(wrapper.find('.mobile-breadcrumb').text()).toContain('Replied')
})
it('clicking a card emits select-item with the item id', async () => {
const items = [makeItem({ id: 42, type: 'donation', stage: 'new' })]
const wrapper = mount(KanbanBoard, { props: { items } })
await wrapper.find('.card').trigger('click')
expect(wrapper.emitted('select-item')).toEqual([[42]])
})
it('renders an empty-state message for a column with no items', () => {
const wrapper = mount(KanbanBoard, { props: { items: [] } })
expect(wrapper.text()).toContain('Nothing here')
})
it('applies the is-active-mobile class only to the currently active stage column', () => {
const items = [
makeItem({ id: 1, type: 'donation', stage: 'new' }),
makeItem({ id: 2, type: 'donation', stage: 'replied' }),
]
const wrapper = mount(KanbanBoard, { props: { items } })
const columns = wrapper.findAll('.column')
const activeColumns = columns.filter((c) => c.classes().includes('is-active-mobile'))
expect(activeColumns).toHaveLength(1)
expect(activeColumns[0].text()).toContain('New')
})
it('ArrowRight on a board tab moves selection and focus to the next tab', async () => {
const wrapper = mount(KanbanBoard, { props: { items: [] }, attachTo: document.body })
const tabs = wrapper.findAll('[role="tab"]')
tabs[0].element.focus()
await tabs[0].trigger('keydown', { key: 'ArrowRight' })
const updatedTabs = wrapper.findAll('[role="tab"]')
expect(updatedTabs[1].attributes('aria-selected')).toBe('true')
expect(document.activeElement).toBe(updatedTabs[1].element)
wrapper.unmount()
})
it('ArrowLeft wraps from the first tab to the last tab', async () => {
const wrapper = mount(KanbanBoard, { props: { items: [] }, attachTo: document.body })
const tabs = wrapper.findAll('[role="tab"]')
await tabs[0].trigger('keydown', { key: 'ArrowLeft' })
const updatedTabs = wrapper.findAll('[role="tab"]')
expect(updatedTabs[3].attributes('aria-selected')).toBe('true')
wrapper.unmount()
})
})

View file

@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest'
import {
DONATION_STAGES, OTHER_STAGES, OUTBOUND_STAGES, UNSORTED_STAGES, STAGE_LABELS,
} from '../src/stages.js'
describe('stages.js', () => {
it('exports the expected stage lists', () => {
expect(DONATION_STAGES).toEqual([
'new', 'replied', 'pickup_scheduled', 'picked_up', 'logged_in_bookmark',
])
expect(OTHER_STAGES).toEqual(['new', 'done'])
expect(OUTBOUND_STAGES).toEqual([
'new', 'reviewed', 'outreach_drafted', 'outreach_sent',
'replied', 'pickup_scheduled', 'picked_up', 'logged_in_bookmark',
])
expect(UNSORTED_STAGES).toEqual(['new'])
})
it('has a human-readable label for every stage across all lists', () => {
const allStages = new Set([
...DONATION_STAGES, ...OTHER_STAGES, ...OUTBOUND_STAGES, ...UNSORTED_STAGES,
])
for (const stage of allStages) {
expect(STAGE_LABELS[stage]).toBeTruthy()
expect(STAGE_LABELS[stage]).not.toBe(stage)
}
})
})

View file

@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'fs'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
const __dirname = dirname(fileURLToPath(import.meta.url))
const themeCss = readFileSync(join(__dirname, '../src/theme.css'), 'utf-8')
describe('theme.css contrast fixes', () => {
it('strengthens --color-border to a WCAG-compliant value in light mode', () => {
expect(themeCss).toContain('--color-border: #8c8c91;')
})
it('strengthens --color-border to a WCAG-compliant value in dark mode', () => {
expect(themeCss).toContain('--color-border: #64646e;')
})
it('darkens --modality-bh_text for 4.5:1 contrast with white text', () => {
expect(themeCss).toContain('--modality-bh_text: #8c643f;')
})
it('darkens --modality-nd_messenger for 4.5:1 contrast with white text', () => {
expect(themeCss).toContain('--modality-nd_messenger: #597186;')
})
it('differentiates --modality-fb_messenger from --modality-bh_email', () => {
const bhEmailMatch = themeCss.match(/--modality-bh_email: (#[0-9a-f]{6});/)
const fbMessengerMatch = themeCss.match(/--modality-fb_messenger: (#[0-9a-f]{6});/)
expect(bhEmailMatch).not.toBeNull()
expect(fbMessengerMatch).not.toBeNull()
expect(bhEmailMatch[1]).not.toBe(fbMessengerMatch[1])
})
})