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
e6575b4
🎨 Palette: [UX improvement] - ContextSection μ•„μ½”λ””μ–Έ μ ‘κ·Όμ„± 및 ν‚€λ³΄λ“œ λ„€λΉ„κ²Œμ΄μ…˜ ν–₯상
seonghobae Jul 5, 2026
1924edf
🎨 Palette: [UX improvement] - ContextSection μ•„μ½”λ””μ–Έ μ ‘κ·Όμ„± 및 ν‚€λ³΄λ“œ λ„€λΉ„κ²Œμ΄μ…˜ ν–₯상
seonghobae Jul 6, 2026
54d6240
🎨 Palette: [UX improvement] - ContextSection μ•„μ½”λ””μ–Έ μ ‘κ·Όμ„± 및 ν‚€λ³΄λ“œ λ„€λΉ„κ²Œμ΄μ…˜ ν–₯상
seonghobae Jul 6, 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-05 - ContextSection μ•„μ½”λ””μ–Έ μ ‘κ·Όμ„± 및 포컀슀 κ°œμ„ 
**Learning:** `ContextSection` 같은 μ‚¬μš©μž μ •μ˜ μ•„μ½”λ””μ–Έ μ»΄ν¬λ„ŒνŠΈλ₯Ό λ§Œλ“€ λ•Œ, λ²„νŠΌκ³Ό μ½˜ν…μΈ  μ˜μ—­μ΄ 슀크린 λ¦¬λ”μ—μ„œ μ˜¬λ°”λ₯΄κ²Œ μ—°κ²°λ˜μ§€ μ•ŠλŠ” λ¬Έμ œκ°€ 자주 λ°œμƒν•©λ‹ˆλ‹€. λ˜ν•œ λ‹¨μˆœνžˆ μ‹œκ°μ μΈ hover μƒνƒœλ§Œ μ£Όμ–΄μ§€κ³  ν‚€λ³΄λ“œ focus μƒνƒœκ°€ λͺ…ν™•ν•˜μ§€ μ•ŠμœΌλ©΄ ν‚€λ³΄λ“œ 탐색 μ‚¬μš©μžμ—κ²Œ λΆˆνŽΈν•¨μ„ μ€λ‹ˆλ‹€.
**Action:** React의 `useId` 훅을 μ‚¬μš©ν•˜μ—¬ λ²„νŠΌ(`aria-controls`)κ³Ό μ½˜ν…μΈ  μ˜μ—­(`id`, `role="region"`, `aria-labelledby`)을 λ™μ μœΌλ‘œ μ•ˆμ „ν•˜κ²Œ μ—°κ²°ν•˜λŠ” νŒ¨ν„΄μ„ μ μš©ν•©λ‹ˆλ‹€. μž₯μ‹μš© μ•„μ΄μ½˜μ€ `aria-hidden="true"`둜 숨기고, λ²„νŠΌμ—λŠ” `focus-visible:ring` Tailwind 클래슀λ₯Ό μΌκ΄€λ˜κ²Œ μΆ”κ°€ν•˜μ—¬ ν‚€λ³΄λ“œ 접근성을 확보해야 ν•©λ‹ˆλ‹€.
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/
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# 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`) 속성을 μΆ”κ°€ν•˜μ—¬ 접근성을 κ°œμ„ ν–ˆμŠ΅λ‹ˆλ‹€.
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@
"dev": "turbo dev",
"build": "turbo build",
"lint": "turbo lint",
"typecheck": "turbo typecheck"
"typecheck": "turbo typecheck",
"test": "turbo test",
"coverage": "turbo test -- --coverage"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@vitest/coverage-v8": "3.2.6",
"eslint": "^9",
"turbo": "^2.9.16",
"typescript-eslint": "^8"
Expand Down
4 changes: 4 additions & 0 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,16 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.2",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/bcryptjs": "^2",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitest/coverage-v8": "^3.2.6",
"dotenv": "^17.4.2",
"eslint-config-next": "^16.2.3",
"jsdom": "^29.1.1",
"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
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import React from 'react'
import { render, screen, fireEvent, cleanup } from '@testing-library/react'
import { describe, it, expect, afterEach } from 'vitest'
import { ContextSection } from './context-section'

/** @vitest-environment jsdom */

describe('ContextSection', () => {
afterEach(() => {
cleanup()
})

it('renders title correctly', () => {
render(
<ContextSection title="Test Title">
<p>Test Content</p>
</ContextSection>
)
expect(screen.getByText('Test Title')).toBeDefined()
})

it('toggles content visibility on click', () => {
render(
<ContextSection title="Test Title">
<div data-testid="content">Test Content</div>
</ContextSection>
)

// Initially closed
expect(screen.queryByTestId('content')).toBeNull()
const button = screen.getByRole('button')
expect(button.getAttribute('aria-expanded')).toBe('false')

// Click to open
fireEvent.click(button)
expect(screen.getByTestId('content')).toBeDefined()
expect(button.getAttribute('aria-expanded')).toBe('true')

// Click to close
fireEvent.click(button)
expect(screen.queryByTestId('content')).toBeNull()
expect(button.getAttribute('aria-expanded')).toBe('false')
})

it('respects defaultOpen prop', () => {
render(
<ContextSection title="Test Title" defaultOpen={true}>
<div data-testid="content">Test Content</div>
</ContextSection>
)

expect(screen.getByTestId('content')).toBeDefined()
const button = screen.getByRole('button')
expect(button.getAttribute('aria-expanded')).toBe('true')
})

it('has correct ARIA attributes for accessibility', () => {
render(
<ContextSection title="Test Title" defaultOpen={true}>
<div data-testid="content">Test Content</div>
</ContextSection>
)

const button = screen.getByRole('button')
const region = screen.getByRole('region')

const buttonControls = button.getAttribute('aria-controls')
const regionId = region.getAttribute('id')
const regionLabelledBy = region.getAttribute('aria-labelledby')
const buttonId = button.getAttribute('id')

expect(buttonControls).toBeTruthy()
expect(regionId).toBeTruthy()
expect(buttonControls).toBe(regionId)

expect(regionLabelledBy).toBeTruthy()
expect(buttonId).toBeTruthy()
expect(regionLabelledBy).toBe(buttonId)
})
})
21 changes: 16 additions & 5 deletions packages/web/src/components/dashboard/reports/context-section.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import { useState, type ReactNode } from 'react'
import React, { useState, useId, type ReactNode } from 'react'
import { ChevronDown, ChevronUp } from 'lucide-react'
import { cn } from '@/lib/utils'

Expand All @@ -12,27 +12,38 @@ interface ContextSectionProps {

export function ContextSection({ title, children, defaultOpen = false }: ContextSectionProps) {
const [open, setOpen] = useState(defaultOpen)
const id = useId()
const headerId = `${id}-header`
const contentId = `${id}-content`

return (
<div className="rounded-xl bg-card ring-1 ring-foreground/10 overflow-hidden">
<button
id={headerId}
type="button"
onClick={() => setOpen((v) => !v)}
className={cn(
'w-full flex items-center justify-between px-4 py-3 text-left',
'w-full flex items-center justify-between px-4 py-3 text-left outline-none rounded-sm',
'hover:bg-card-elevated transition-colors',
'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset'
)}
aria-expanded={open}
aria-controls={contentId}
>
<h2 className="text-base font-medium">{title}</h2>
{open ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
<ChevronUp aria-hidden="true" className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
<ChevronDown aria-hidden="true" className="h-4 w-4 text-muted-foreground" />
)}
</button>
{open && (
<div className="px-4 pb-4 pt-1">
<div
id={contentId}
role="region"
aria-labelledby={headerId}
className="px-4 pb-4 pt-1"
>
{children}
</div>
)}
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
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
'use client'

import { useMemo, useState, useRef } from 'react'
import { useState, useRef } from 'react'
import {
formatSlashCommandText,
buildTimelineGroups,
type TimelineEvent,
type TimelineGroup,
} from '@/lib/timeline-events'
import { formatTokens, formatCost, formatRelativeTime } from '@/lib/format'
import { segmentVisuals } from './session-ribbon-visuals'

type Props = {
events: TimelineEvent[]
groups: TimelineGroup[]
selectedIdx: number | null
onSelect: (idx: number) => void
sessionStartedAt: string
Expand Down Expand Up @@ -147,6 +148,7 @@ function MergedTooltipBody({

export function SessionActivityRibbon({
events,
groups,
selectedIdx,
onSelect,
sessionStartedAt,
Expand All @@ -156,8 +158,6 @@ export function SessionActivityRibbon({
const [hover, setHover] = useState<HoverState | null>(null)
const containerRef = useRef<HTMLDivElement>(null)

const groups = useMemo(() => buildTimelineGroups(events), [events])

if (events.length === 0) return null

const trackMouse = (e: React.MouseEvent) => {
Expand Down
21 changes: 12 additions & 9 deletions packages/web/src/components/dashboard/token-usage-chart.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client'

import { useMemo } from 'react'
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, TooltipProps } from 'recharts'
import { formatTokens, formatCost } from '@/lib/format'
import type { UsageSeries } from '@argos/shared'
Expand Down Expand Up @@ -43,15 +44,17 @@ function CustomTooltip({ active, payload, label }: TooltipProps<number, string>)
}

export function TokenUsageChart({ data }: TokenUsageChartProps) {
const chartData = data.map(d => {
const date = new Date(d.date)
return {
date: format(date, 'MMM d'),
fullDate: format(date, 'MMM d, yyyy'),
input: d.inputTokens,
output: d.outputTokens,
}
})
const chartData = useMemo(() => {
return data.map(d => {
const date = new Date(d.date)
return {
date: format(date, 'MMM d'),
fullDate: format(date, 'MMM d, yyyy'),
input: d.inputTokens,
output: d.outputTokens,
}
})
}, [data])

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