Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
13 commits
Select commit Hold shift + click to select a range
9813e45
โšก Bolt: hoist buildTimelineGroups useMemo up to page.tsx to avoid dupโ€ฆ
seonghobae Jun 22, 2026
3547906
refactor(web): optimize multiple reduce loops in weekly reports
seonghobae Jun 22, 2026
649df11
feat(ux): add focus-visible styles to overview stats toggle button
seonghobae Jun 22, 2026
a1e0025
โšก Bolt: ์ฐจํŠธ ์ปดํฌ๋„ŒํŠธ์˜ ๋ฐ์ดํ„ฐ ๋ณ€ํ™˜ ๋กœ์ง์— useMemo ์ ์šฉ
seonghobae Jun 23, 2026
3e2eeac
๐ŸŽจ Palette: [์ ‘๊ทผ์„ฑ ๊ฐœ์„ ] ๋กœ๊ทธ์•„์›ƒ ๋ฒ„ํŠผ์— aria-label ์ถ”๊ฐ€
seonghobae Jun 23, 2026
48388f7
Merge pull request #104 from ContextualWisdomLab/palette/accessibilitโ€ฆ
seonghobae Jun 23, 2026
8a7b265
Merge pull request #103 from ContextualWisdomLab/bolt-optimize-chart-โ€ฆ
seonghobae Jun 23, 2026
3ef540b
Merge pull request #102 from ContextualWisdomLab/feat/a11y-overview-sโ€ฆ
seonghobae Jun 23, 2026
0ea0ea5
Merge pull request #101 from ContextualWisdomLab/bolt/optimize-reduceโ€ฆ
seonghobae Jun 23, 2026
339134f
Merge pull request #100 from ContextualWisdomLab/bolt-perf-build-timeโ€ฆ
seonghobae Jun 23, 2026
4158ebb
๐ŸŽจ Palette: ๋‚ ์งœ ๋ฒ”์œ„ ์„ ํƒ๊ธฐ ์ ‘๊ทผ์„ฑ ๊ฐœ์„ 
seonghobae Jul 4, 2026
fdbe03e
๐ŸŽจ Palette: Add unit tests for DateRangePicker accessibility
seonghobae Jul 4, 2026
6c76600
๐ŸŽจ Palette: Add unit tests for DateRangePicker accessibility
seonghobae Jul 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .Jules/palette.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
## 2024-06-22 - Overview Stats ํ† ๊ธ€ ๋ฒ„ํŠผ ํ‚ค๋ณด๋“œ ์ ‘๊ทผ์„ฑ ๊ฐœ์„ 
**Learning:** `overview-stats` ์ปดํฌ๋„ŒํŠธ์˜ ์„ค๋ช… ํ…์ŠคํŠธ๋ฅผ ํŽผ์น˜๊ฑฐ๋‚˜ ์ ‘๋Š” `<button>` ์š”์†Œ์— ํ‚ค๋ณด๋“œ ํฌ์ปค์Šค ์Šคํƒ€์ผ์ด ๋ˆ„๋ฝ๋˜์–ด ์žˆ์–ด, ํ‚ค๋ณด๋“œ ๋‚ด๋น„๊ฒŒ์ด์…˜ ์‚ฌ์šฉ์ž์—๊ฒŒ ํ˜„์žฌ ํฌ์ปค์Šค ์œ„์น˜๋ฅผ ๋ช…ํ™•ํžˆ ๋ณด์—ฌ์ฃผ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. `hover` ์Šคํƒ€์ผ์€ ์žˆ์—ˆ์œผ๋‚˜ `focus-visible` ์ฒ˜๋ฆฌ๊ฐ€ ์—†์–ด ์ ‘๊ทผ์„ฑ ๊ฒฐํ•จ์ด ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค.
**Action:** Tailwind CSS์˜ `focus-visible` ๊ด€๋ จ ์œ ํ‹ธ๋ฆฌํ‹ฐ ํด๋ž˜์Šค(`focus-visible:outline-none`, `focus-visible:ring-2`, `focus-visible:ring-ring`, `rounded-sm`)๋ฅผ ์ถ”๊ฐ€ํ•˜์—ฌ ํƒญ(Tab) ํ‚ค ์ด๋™ ์‹œ ํฌ์ปค์Šค ๋ง์ด ๋ณด์ด๋„๋ก ์ˆ˜์ •ํ–ˆ์Šต๋‹ˆ๋‹ค. ์•ž์œผ๋กœ ๋Œ€ํ™”ํ˜• ์ปดํฌ๋„ŒํŠธ๋ฅผ ์„ค๊ณ„ํ•  ๋•Œ๋Š” ํ•ญ์ƒ ํ‚ค๋ณด๋“œ ์ ‘๊ทผ์„ฑ(focus states)์„ ์—ผ๋‘์— ๋‘๊ณ  ์ž‘์—…ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.
## 2024-07-04 - Segment Controls State Accessibility
**Learning:** React segment controls/toggles (like preset buttons) built with basic buttons often visually distinguish the active state (e.g. using a background color) but fail to communicate this to screen readers. They also easily miss standard keyboard focus outlines when built with custom padding/borders.
**Action:** ํ•ญ์ƒ ์ปค์Šคํ…€ ํ† ๊ธ€/์„ธ๊ทธ๋จผํŠธ ์ปจํŠธ๋กค ๋ฒ„ํŠผ์„ ๊ตฌํ˜„ํ•  ๋•Œ๋Š” `aria-pressed={isActive}` ์†์„ฑ์„ ์ถ”๊ฐ€ํ•˜์—ฌ ์ƒํƒœ๋ฅผ ์Šคํฌ๋ฆฐ ๋ฆฌ๋”์— ๋ช…์‹œ์ ์œผ๋กœ ์•Œ๋ฆฌ๊ณ , `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1` ํด๋ž˜์Šค๋ฅผ ์ ์šฉํ•ด ํ‚ค๋ณด๋“œ ๋„ค๋น„๊ฒŒ์ด์…˜ ์‹œ ๋ช…ํ™•ํ•œ ์‹œ๊ฐ์  ํ”ผ๋“œ๋ฐฑ์„ ์ œ๊ณตํ•ด์•ผ ํ•œ๋‹ค.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ __pycache__/
.jules/
.state/
.claude/worktrees/
coverage/
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Changelog

## [Unreleased]

### ๐ŸŽจ ๋ณ€๊ฒฝ ์‚ฌํ•ญ (UX / ์ ‘๊ทผ์„ฑ)

- ์›น ๋Œ€์‹œ๋ณด๋“œ์˜ ๊ฐ์ข… ๋กœ๊ทธ์•„์›ƒ ๋ฒ„ํŠผ(`org-sidebar.tsx`, `org-header.tsx`, `no-organization-state.tsx`)์— ์Šคํฌ๋ฆฐ ๋ฆฌ๋”์šฉ `aria-label="Log out of your account"` (๋˜๋Š” `Sign out of your account`) ์†์„ฑ์„ ์ถ”๊ฐ€ํ•˜์—ฌ ์ ‘๊ทผ์„ฑ์„ ๊ฐœ์„ ํ–ˆ์Šต๋‹ˆ๋‹ค.

### Fixed
- ๋‚ ์งœ ๋ฒ”์œ„ ์„ ํƒ๊ธฐ์˜ ์ ‘๊ทผ์„ฑ์„ ๊ฐœ์„ ํ–ˆ์Šต๋‹ˆ๋‹ค (Screen Reader ๋ฐ ํ‚ค๋ณด๋“œ ๋„ค๋น„๊ฒŒ์ด์…˜).
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,16 @@
"dev": "turbo dev",
"build": "turbo build",
"lint": "turbo lint",
"typecheck": "turbo typecheck"
"typecheck": "turbo typecheck",
"test": "pnpm -r run test",
"coverage": "pnpm -r run test --coverage"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@vitest/coverage-v8": "3.2.6",
"eslint": "^9",
"turbo": "^2.9.16",
"typescript-eslint": "^8"
Expand Down
3 changes: 3 additions & 0 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,11 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "4",
"@vitest/coverage-v8": "3.2.6",
"dotenv": "^17.4.2",
"eslint-config-next": "^16.2.3",
"happy-dom": "^20.10.6",
"prisma": "^6",
"shadcn": "^4.10.0",
"tailwindcss": "^4",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
SessionFilesTab,
} from '@/components/dashboard/session-files'
import { useSessionDetail } from '@/hooks/use-dashboard-sessions'
import { messagesToTimeline, formatSlashCommandText } from '@/lib/timeline-events'
import { messagesToTimeline, buildTimelineGroups, formatSlashCommandText } from '@/lib/timeline-events'
import { extractSessionFiles } from '@/lib/session-files'
import {
formatTokens,
Expand Down Expand Up @@ -46,6 +46,7 @@ export default function OrgSessionDetailPage({
() => (data ? messagesToTimeline(data.messages) : []),
[data],
)
const groups = useMemo(() => buildTimelineGroups(events), [events])
const files = useMemo(() => extractSessionFiles(events), [events])
const [selectedIdx, setSelectedIdx] = useState<number | null>(0)
const safeIdx =
Expand Down Expand Up @@ -221,6 +222,7 @@ export default function OrgSessionDetailPage({
<div className="px-4 pt-3 pb-2">
<SessionActivityRibbon
events={events}
groups={groups}
selectedIdx={safeIdx}
onSelect={setSelectedIdx}
sessionStartedAt={data.startedAt}
Expand All @@ -232,6 +234,7 @@ export default function OrgSessionDetailPage({
<div className="border-r border-border min-h-0 overflow-hidden">
<EventList
events={events}
groups={groups}
selectedIdx={safeIdx ?? -1}
onSelect={setSelectedIdx}
sessionStartedAt={data.startedAt}
Expand Down
188 changes: 188 additions & 0 deletions packages/web/src/components/dashboard/date-range-picker.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'
import { render, screen, fireEvent, cleanup } from '@testing-library/react'
import { DateRangePicker } from './date-range-picker'
import { useRouter, useSearchParams } from 'next/navigation'
import { format, subDays } from 'date-fns'

vi.mock('next/navigation', () => ({
useRouter: vi.fn(),
useSearchParams: vi.fn(),
}))

describe('DateRangePicker', () => {
const TODAY = new Date('2024-07-04T00:00:00Z')

beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(TODAY)
})

afterEach(() => {
cleanup()
vi.useRealTimers()
})

it('renders presets correctly and handles click', () => {
const mockPush = vi.fn()
vi.mocked(useRouter).mockReturnValue({ push: mockPush } as unknown as ReturnType<typeof vi.fn>)
const from = format(subDays(TODAY, 6), 'yyyy-MM-dd')
const to = format(TODAY, 'yyyy-MM-dd')
vi.mocked(useSearchParams).mockReturnValue(new URLSearchParams(`?from=${from}&to=${to}`))

render(<DateRangePicker />)

const button7d = screen.getByRole('button', { name: '7d' })
const button30d = screen.getByRole('button', { name: '30d' })
const button90d = screen.getByRole('button', { name: '90d' })
const buttonAll = screen.getByRole('button', { name: 'ALL' })

expect(button7d).toBeDefined()
expect(button30d).toBeDefined()

// Default is 7 days, check aria-pressed
expect(button7d.getAttribute('aria-pressed')).toBe('true')
expect(button30d.getAttribute('aria-pressed')).toBe('false')

fireEvent.click(button30d)

const from30 = format(subDays(TODAY, 30), 'yyyy-MM-dd')
const to30 = format(TODAY, 'yyyy-MM-dd')

expect(mockPush).toHaveBeenCalledWith(`?from=${from30}&to=${to30}`)

// 90d click
fireEvent.click(button90d)
const from90 = format(subDays(TODAY, 90), 'yyyy-MM-dd')
const to90 = format(TODAY, 'yyyy-MM-dd')
expect(mockPush).toHaveBeenCalledWith(`?from=${from90}&to=${to90}`)

// ALL click
fireEvent.click(buttonAll)
const fromAll = format(subDays(TODAY, 3650), 'yyyy-MM-dd')
const toAll = format(TODAY, 'yyyy-MM-dd')
expect(mockPush).toHaveBeenCalledWith(`?from=${fromAll}&to=${toAll}`)

})

it('identifies custom date range', () => {
vi.mocked(useRouter).mockReturnValue({ push: vi.fn() } as unknown as ReturnType<typeof vi.fn>)
vi.mocked(useSearchParams).mockReturnValue(new URLSearchParams('?from=2023-01-01&to=2023-01-10'))

render(<DateRangePicker />)

const button7d = screen.getByRole('button', { name: '7d' })
const button30d = screen.getByRole('button', { name: '30d' })

expect(button7d.getAttribute('aria-pressed')).toBe('false')
expect(button30d.getAttribute('aria-pressed')).toBe('false')
})

it('identifies 30d preset correctly from url', () => {
const from = format(subDays(TODAY, 29), 'yyyy-MM-dd')
const to = format(TODAY, 'yyyy-MM-dd')

vi.mocked(useRouter).mockReturnValue({ push: vi.fn() } as unknown as ReturnType<typeof vi.fn>)
vi.mocked(useSearchParams).mockReturnValue(new URLSearchParams(`?from=${from}&to=${to}`))

render(<DateRangePicker />)

const button30d = screen.getByRole('button', { name: '30d' })
expect(button30d.getAttribute('aria-pressed')).toBe('true')
})

it('identifies 90d preset correctly from url', () => {
const from = format(subDays(TODAY, 89), 'yyyy-MM-dd')
const to = format(TODAY, 'yyyy-MM-dd')

vi.mocked(useRouter).mockReturnValue({ push: vi.fn() } as unknown as ReturnType<typeof vi.fn>)
vi.mocked(useSearchParams).mockReturnValue(new URLSearchParams(`?from=${from}&to=${to}`))

render(<DateRangePicker />)

const button90d = screen.getByRole('button', { name: '90d' })
expect(button90d.getAttribute('aria-pressed')).toBe('true')
})

it('identifies ALL preset correctly from url', () => {
const from = format(subDays(TODAY, 3649), 'yyyy-MM-dd')
const to = format(TODAY, 'yyyy-MM-dd')

vi.mocked(useRouter).mockReturnValue({ push: vi.fn() } as unknown as ReturnType<typeof vi.fn>)
vi.mocked(useSearchParams).mockReturnValue(new URLSearchParams(`?from=${from}&to=${to}`))

render(<DateRangePicker />)

const buttonAll = screen.getByRole('button', { name: 'ALL' })
expect(buttonAll.getAttribute('aria-pressed')).toBe('true')
})
})

describe('DateRangePicker default params coverage', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2024-07-04T00:00:00Z'))
})

afterEach(() => {
cleanup()
vi.useRealTimers()
})

it('covers daysDiff logic when isToday=false', () => {
vi.mocked(useRouter).mockReturnValue({ push: vi.fn() } as unknown as ReturnType<typeof vi.fn>)
vi.mocked(useSearchParams).mockReturnValue(new URLSearchParams('?from=2024-07-01&to=2024-07-02'))
render(<DateRangePicker />)
const button7d = screen.getByRole('button', { name: '7d' })
expect(button7d.getAttribute('aria-pressed')).toBe('false')
})

it('covers currentTo null', () => {
vi.mocked(useRouter).mockReturnValue({ push: vi.fn() } as unknown as ReturnType<typeof vi.fn>)
const p = new URLSearchParams()
p.set('from', '2024-07-01')
vi.mocked(useSearchParams).mockReturnValue(p)
render(<DateRangePicker />)
const button7d = screen.getByRole('button', { name: '7d' })
expect(button7d.getAttribute('aria-pressed')).toBe('false')
})
})

describe('DateRangePicker default params coverage edge cases', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2024-07-04T00:00:00Z'))
})

afterEach(() => {
cleanup()
vi.useRealTimers()
})

it('covers daysDiff no match', () => {
vi.mocked(useRouter).mockReturnValue({ push: vi.fn() } as ReturnType<typeof vi.fn>)
vi.mocked(useSearchParams).mockReturnValue(new URLSearchParams('?from=2024-07-03&to=2024-07-04'))
render(<DateRangePicker />)
const button7d = screen.getByRole('button', { name: '7d' })
expect(button7d.getAttribute('aria-pressed')).toBe('false')
})
})

describe('DateRangePicker params from null', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2024-07-04T00:00:00Z'))
})

afterEach(() => {
cleanup()
vi.useRealTimers()
})

it('covers currentFrom null', () => {
vi.mocked(useRouter).mockReturnValue({ push: vi.fn() } as ReturnType<typeof vi.fn>)
vi.mocked(useSearchParams).mockReturnValue(new URLSearchParams('?to=2024-07-04'))
render(<DateRangePicker />)
const button7d = screen.getByRole('button', { name: '7d' })
expect(button7d.getAttribute('aria-pressed')).toBe('false')
})
})
3 changes: 2 additions & 1 deletion packages/web/src/components/dashboard/date-range-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,10 @@ function DateRangePickerContent() {
<button
key={preset.days}
type="button"
aria-pressed={activePreset === preset.days}
onClick={() => handlePreset(preset.days)}
className={cn(
'px-3 py-1 text-xs font-medium rounded-md transition-colors',
'px-3 py-1 text-xs font-medium rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
activePreset === preset.days
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-muted',
Expand Down
11 changes: 6 additions & 5 deletions packages/web/src/components/dashboard/event-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ import { List, type RowComponentProps } from "react-window";
import { User, Bot, Wrench, ChevronRight } from "lucide-react";
import {
formatSlashCommandText,
buildTimelineGroups,
type TimelineEvent,
type TimelineGroup,
} from "@/lib/timeline-events";
import { cn } from "@/lib/utils";

type EventListProps = {
events: TimelineEvent[];
groups: TimelineGroup[];
selectedIdx: number;
onSelect: (idx: number) => void;
sessionStartedAt: string;
Expand Down Expand Up @@ -50,11 +51,10 @@ function formatElapsed(timestamp: string, sessionStartedAt: string): string {
}

function buildFlatRows(
events: TimelineEvent[],
groups: TimelineGroup[],
expandedGroups: Set<number>,
selectedIdx: number,
): FlatRow[] {
const groups = buildTimelineGroups(events);
const rows: FlatRow[] = [];
for (const group of groups) {
if (group.kind === "single") {
Expand Down Expand Up @@ -261,15 +261,16 @@ function Row({

export function EventList({
events,
groups,
selectedIdx,
onSelect,
sessionStartedAt,
expandedGroups,
onToggleGroup,
}: EventListProps) {
const rows = useMemo(
() => buildFlatRows(events, expandedGroups, selectedIdx),
[events, expandedGroups, selectedIdx],
() => buildFlatRows(groups, expandedGroups, selectedIdx),
[groups, expandedGroups, selectedIdx],
);

if (events.length === 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export function NoOrganizationState({
<button
onClick={() => signOut({ callbackUrl: '/login' })}
className="text-sm text-destructive hover:underline"
aria-label="Log out of your account"
>
Log out
</button>
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/components/dashboard/overview-stats.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export function OverviewStats({
id="overview-stats-explanation-toggle"
aria-expanded={expanded}
aria-controls="overview-stats-explanation"
className="mt-4 flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
className="mt-4 flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring transition-colors rounded-sm"
>
<span
aria-hidden="true"
Expand Down
27 changes: 16 additions & 11 deletions packages/web/src/components/dashboard/reports/weekly-flow-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
Legend,
type TooltipProps,
} from 'recharts'
import { useMemo } from 'react'
import { parseISO } from 'date-fns'
import { formatTokens } from '@/lib/format'
import type { DailySeriesPoint } from '@/types/reports'
Expand Down Expand Up @@ -57,18 +58,22 @@ function sumTokens(p: DailySeriesPoint): number {
}

export function WeeklyFlowChart({ thisWeek, prevWeek }: WeeklyFlowChartProps) {
// ์š”์ผ ๊ธฐ์ค€์œผ๋กœ ๋ณ‘ํ•ฉ (์›”~์ผ 7๊ฐœ ์Šฌ๋กฏ)
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const data: ChartRow[] = dayNames.map((day) => ({ day, thisWeekTokens: 0, prevWeekTokens: 0 }))
const data = useMemo(() => {
// ์š”์ผ ๊ธฐ์ค€์œผ๋กœ ๋ณ‘ํ•ฉ (์›”~์ผ 7๊ฐœ ์Šฌ๋กฏ)
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const result: ChartRow[] = dayNames.map((day) => ({ day, thisWeekTokens: 0, prevWeekTokens: 0 }))

for (const p of thisWeek) {
const idx = dayIndex(p.date)
if (idx >= 0) data[idx].thisWeekTokens += sumTokens(p)
}
for (const p of prevWeek) {
const idx = dayIndex(p.date)
if (idx >= 0) data[idx].prevWeekTokens += sumTokens(p)
}
for (const p of thisWeek) {
const idx = dayIndex(p.date)
if (idx >= 0) result[idx].thisWeekTokens += sumTokens(p)
}
for (const p of prevWeek) {
const idx = dayIndex(p.date)
if (idx >= 0) result[idx].prevWeekTokens += sumTokens(p)
}

return result
}, [thisWeek, prevWeek])

return (
<ResponsiveContainer width="100%" height={260}>
Expand Down
Loading
Loading