Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 11 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,16 +116,17 @@ git push origin v1.1.0
- **JaCoCo**: 코드 커버리지 측정 (`./gradlew :library:jacocoTestReport`) + 임계값 검증 80% (`./gradlew :library:jacocoCoverageVerification`)
- **Android Lint**: 정적 분석 (`./gradlew :library:lint`)
- **Dokka 2.0**: API 문서 생성 (`./gradlew :library:dokkaGenerate`)
- **ProGuard Consumer Rules**: `library/consumer-rules.pro`에 public API 보호 규칙 정의
- **ProGuard Consumer Rules**: `library/consumer-rules.pro`에 public API 보호 규칙과 패키지 범위 Compose keep 규칙 정의
- **성능 벤치마크**: `GridBenchmarkTest.kt`에서 대량 데이터(1000~3650일) 성능 검증
- **Compose Compiler Reports**: 필요 시 `-PcomposeCompilerReports=true`로 metrics/reports 생성
- **CHANGELOG**: `CHANGELOG.md`에 Keep a Changelog 형식으로 변경 이력 관리

## 라이브러리 소스 구조

- `library/src/main/java/com/inseong/gitgrass/`
- `GitGrass.kt` — 메인 컴포저블 (public API)
- `GitGrassColors.kt` — 색상 스킴 데이터 클래스
- `GitGrassComponents.kt` — 내부 UI 컴포넌트 (YearLabel, MonthRow, WeekLabelColumn, GrassGridContent, GrassWeekColumn, GrassCell, StreakSummary, ColorLegend)
- `GitGrassComponents.kt` — 내부 UI 컴포넌트 (YearLabel, MonthRow, WeekLabelColumn, GrassGridContent, Canvas 기반 GrassWeekColumn, GrassCell hit target, StreakSummary, ColorLegend)
- `GitGrassDefaults.kt` — 기본값 및 팩토리 (색상, 라벨, 크기, 로케일, 레이아웃 상수)
- `GridUtils.kt` — 순수 함수 유틸리티 (normalizeDateRange, normalizeContributions, generateDayList, buildGrid, dayIndexInWeek, weekDaysOrdered, createMonthLabels, formatYearLabel, calculateStreak)
- `RenderData.kt` — 셀별 count/color/접근성 라벨을 미리 계산하는 렌더링 데이터 유틸리티
Expand All @@ -138,7 +139,7 @@ git push origin v1.1.0
- `GitGrassDefaultsTest.kt` — startDate, endDate, levelThresholds, 로케일 라벨, 색상
- `LevelToColorTest.kt` — 색상 매핑 (8개)
- `RenderDataTest.kt` — 렌더링 데이터 사전 계산 검증
- `GridBenchmarkTest.kt` — 성능 벤치마크 (5개)
- `GridBenchmarkTest.kt` — 성능 벤치마크 (6개)
- `library/src/androidTest/` — Compose UI 인스트루먼트 테스트
- `GrassCellTest.kt` — 셀 클릭/롱클릭 콜백 검증
- `LabelRenderingTest.kt` — 월 라벨, 주 라벨 렌더링 검증
Expand Down Expand Up @@ -169,6 +170,13 @@ git push origin v1.1.0
- 모든 작업이 완료되면 반드시 git commit 후 `git push`까지 수행한다.
- 커밋 메시지는 한국어로, conventional commit 형식을 따른다 (예: `feat:`, `fix:`, `refactor:`, `docs:`).

### 5. PR 생성 규칙
- Branch Protection 때문에 `main`에 직접 push하지 않고 작업 브랜치를 push한 뒤 PR을 생성한다.
- PR은 기본적으로 **Ready for review** 상태로 생성한다.
- 사용자가 명시적으로 요청하지 않는 한 Draft PR로 생성하지 않는다.
- `gh pr create` 사용 시 `--draft` 옵션을 넣지 않는다. 실수로 Draft PR을 만들었다면 즉시 `gh pr ready`로 전환한다.
- PR 본문에는 변경 요약, 문서 업데이트 여부, 실행한 테스트 결과, public API 영향 여부를 포함한다.

### 커밋 메시지 형식

```
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@
- 셀/범례 렌더링에서 Shape 재사용 및 불필요한 clip 제거로 반복 셀 할당 비용 감소
- 셀별 count, color, 접근성 라벨을 렌더링 전용 데이터로 미리 계산해 리컴포지션 중 반복 계산 감소
- 샘플 앱에서 날짜 범위, 로케일 라벨, 커스텀 색상 팔레트를 `remember`로 재사용하도록 개선
- 그리드와 월 라벨 행을 주 단위 `LazyRow`로 전환해 긴 날짜 범위에서 화면 밖 UI 구성 비용 감소
- 셀 색상 렌더링을 내부 Canvas 경로로 전환하고, 접근성/클릭/롱클릭은 투명 hit target overlay로 유지
- ProGuard consumer rules의 Compose/Immutable keep 범위를 라이브러리 패키지로 축소
- `-PcomposeCompilerReports=true` 사용 시 Compose compiler metrics/reports를 생성하도록 opt-in 설정 추가
- Lazy/Canvas 렌더링 데이터 준비 경로의 10년 범위 성능 벤치마크 추가
- PR을 Draft가 아닌 Ready for review 상태로 생성하도록 에이전트 작업 문서에 명시

## [1.1.0] - 2026-03-13

Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
- 개발 작업 시작 전 계획 수립(Plan Mode First)
- 구현 시 테스트 가능성 우선(순수 함수/관심사 분리/작은 단위)
- 작업 완료 시 커밋 + 푸시
- PR 생성 시 Draft가 아닌 Ready for review 상태로 생성
- 커밋 메시지는 한국어 Conventional Commit 형식 사용

세부 규칙은 `AGENTS.md`의 Workflow Rules를 확인한다.
31 changes: 24 additions & 7 deletions CODE_QUALITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,11 @@ fun GitGrass(...) {
val grid = remember(days, weekStartDay) { buildGrid(days, weekStartDay) }

// 3. Compose 상태
val scrollState = rememberScrollState()
val scrollState = rememberLazyListState()

// 4. 부수 효과 (LaunchedEffect)
LaunchedEffect(grid) {
scrollState.scrollTo(scrollState.maxValue)
// 최신 날짜가 보이도록 lazy list 위치 조정
}

// 5. 레이아웃 구성
Expand Down Expand Up @@ -156,10 +156,10 @@ UI 렌더링 (Composable + remember)
```
GitGrass (루트 조율자)
├── YearLabel
├── MonthRow (scrollState 공유)
├── MonthRow (LazyListState 공유)
├── WeekLabelColumn
├── GrassGridContent (scrollState 공유)
│ └── GrassWeekColumn → GrassCell
├── GrassGridContent (LazyListState 공유)
│ └── GrassWeekColumn (Canvas) → GrassCell (hit target)
├── StreakSummary
└── ColorLegend
```
Expand Down Expand Up @@ -285,11 +285,28 @@ val renderGrid = remember(grid, safeContributions, colors, levelOf) {

정규화가 필요 없는 입력 컬렉션은 원본을 그대로 반환한다. 값 변경이 실제로 필요한 경우에만 새 컬렉션을 만든다.

### Lazy 렌더링 우선

주 단위처럼 반복 개수가 날짜 범위에 비례하는 UI는 eager `Row`보다 `LazyRow`를 우선한다. 월 라벨과 그리드는 같은 `LazyListState`를 공유해 정렬과 스크롤 동기화를 유지한다.

### Canvas와 Semantics 분리

반복 셀의 시각 렌더링은 Canvas로 합치되, 접근성/클릭/롱클릭은 별도의 hit target 컴포저블로 유지한다. 사용자가 렌더러를 직접 선택하는 public 옵션은 실제 요구가 생기기 전까지 추가하지 않는다.

### Consumer Rules 범위 제한

AAR consumer ProGuard 규칙은 앱 전체에 영향을 주므로 `com.inseong.gitgrass.**`처럼 라이브러리 패키지 범위로 한정한다. 전역 `class *` keep 규칙은 피한다.

### Compose Compiler 리포트

Compose compiler metrics/reports는 기본 빌드에 영향을 주지 않도록 Gradle property로 opt-in한다. 필요할 때만 `-PcomposeCompilerReports=true`로 생성한다.

### 벤치마크 기준

| 연산 | 데이터 규모 | 제한 |
|------|------------|------|
| 단일 연산 (generateDayList, buildGrid, buildRenderGrid, calculateStreak) | 1,000일 | 100회 평균 < 100ms |
| Lazy/Canvas 렌더링 데이터 준비 (buildRenderGrid) | 3,650일 (10년) | 30회 평균 < 250ms |
| 전체 파이프라인 | 3,650일 (10년) | 10회 평균 < 500ms |

새로운 유틸리티 함수 추가 시 대량 데이터 벤치마크를 `GridBenchmarkTest.kt`에 추가한다.
Expand Down Expand Up @@ -575,8 +592,8 @@ fun GitGrass(...) {
// O: 컴포지션 — 작은 컴포저블 조합
GitGrass (루트 조율자)
├── YearLabel // 독립 컴포넌트
├── MonthRow // scrollState를 파라미터로 주입
├── GrassGridContent // 데이터를 파라미터로 주입
├── MonthRow // LazyListState를 파라미터로 주입
├── GrassGridContent // 렌더링 데이터와 LazyListState를 파라미터로 주입
└── ColorLegend // 독립 컴포넌트

// X: 상속 기반 접근
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ GitGrass(

대부분의 1년 단위 그래프는 기본 설정만으로 충분히 가볍게 동작합니다. 여러 개의 그래프를 한 화면에 렌더링하거나 5년 이상의 긴 범위를 표시한다면 아래 원칙을 권장합니다:

- 별도 `renderMode`를 고를 필요는 없습니다. 내부에서 주 단위 lazy 렌더링과 Canvas 기반 셀 그리기를 자동으로 사용합니다.
- `contributions`, `startDate`, `endDate`, 로케일 라벨, 커스텀 색상 팔레트는 `remember`나 ViewModel 상태로 안정적으로 전달하세요.
- 필요 없는 보조 UI는 `showMonthLabels`, `showStreak`, `showLegend`를 `false`로 꺼두면 해당 계산과 렌더링 비용을 줄일 수 있습니다.
- 매 리컴포지션마다 `LocalDate.now()`, `localizedMonthLabels()`, `GitGrassColors(...)`를 새로 만들기보다 한 번 계산한 값을 재사용하세요.
Expand Down Expand Up @@ -261,7 +262,8 @@ GitGrass(
- **Pure Compose Foundation** - Material3 의존 없이 Foundation의 `BasicText`만 사용하여, 어떤 디자인 시스템을 쓰는 프로젝트에서도 충돌 없이 동작합니다.
- **유연한 색상 레벨** - 고정된 `level1`~`level4` 대신 `levels: List<Color>`를 사용합니다. 3단계든, 5단계든, 10단계든 자유롭게 지정할 수 있습니다.
- **픽셀 단위 월 라벨 정렬** - 월 라벨이 그리드와 동일한 `Arrangement.spacedBy` + 고정 너비 슬롯 구조를 사용하여, 어떤 셀 크기에서도 정확하게 정렬됩니다.
- **공유 ScrollState** - 월 라벨 행과 그리드가 하나의 `ScrollState`를 공유하여 가로 스크롤 시 항상 동기화됩니다.
- **자동 최적화 렌더링** - 긴 날짜 범위에서는 주 단위 `LazyRow`가 화면에 필요한 열만 구성하고, 셀 색상은 Canvas로 그려 반복 노드 비용을 줄입니다.
- **공유 LazyListState** - 월 라벨 행과 그리드가 하나의 `LazyListState`를 공유하여 가로 스크롤 시 항상 동기화됩니다.
- **순수 함수 & 테스트** - 그리드/스트릭 계산 로직이 부수효과 없는 순수 함수로 구현되어 있으며, 60+ 유닛 테스트와 Compose UI 테스트가 그리드 생성, 스트릭 계산, 색상 매핑, 엣지 케이스, UI 인터랙션을 검증합니다.
- **접근성** - 모든 셀에 contentDescription, semantics 적용으로 스크린 리더를 지원합니다.
- **오늘 날짜로 자동 스크롤** - `LaunchedEffect`로 첫 컴포지션 시 가장 최근 날짜(오른쪽 끝)로 자동 스크롤됩니다.
Expand Down
3 changes: 2 additions & 1 deletion docs/DOCS_CLASSIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

### AGENTS.md
- 대상: AGENTS.md를 우선 읽는 자동화/에이전트 작업자
- 역할: 작업 규칙, 커밋 규칙, 문서 인덱스 및 작업 기록
- 역할: 작업 규칙, 커밋/PR 규칙, 문서 인덱스 및 작업 기록
- 성격: 내부 작업 지침 + 기록 허브

### CODE_QUALITY.md
Expand Down Expand Up @@ -49,6 +49,7 @@

- Public API 변경: `README.md`, `CHANGELOG.md`, 필요 시 `CODE_QUALITY.md`
- 빌드/테스트/배포 파이프라인 변경: `AGENTS.md`, `CLAUDE.md`
- 브랜치/커밋/PR 운영 규칙 변경: `AGENTS.md`, 요약 반영(`CLAUDE.md`)
- 코드 작성 규칙/리뷰 기준 변경: `CODE_QUALITY.md`, 요약 반영(`AGENTS.md`, `CLAUDE.md`)
- 릴리즈 태그/버전 변경: `CHANGELOG.md`, `README.md` 버전 표기

Expand Down
11 changes: 11 additions & 0 deletions library/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,17 @@ android {
}
}

composeCompiler {
val reportsEnabled = providers.gradleProperty("composeCompilerReports")
.map { it.equals("true", ignoreCase = true) }
.getOrElse(false)

if (reportsEnabled) {
reportsDestination = layout.buildDirectory.dir("compose_compiler/reports")
metricsDestination = layout.buildDirectory.dir("compose_compiler/metrics")
}
}

jacoco {
toolVersion = "0.8.12"
}
Expand Down
8 changes: 4 additions & 4 deletions library/consumer-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@
public <methods>;
}

# Compose @Composable 어노테이션 메서드 보호
-keepclassmembers class * {
# Compose @Composable 어노테이션 메서드 보호 (라이브러리 패키지로 한정)
-keepclassmembers class com.inseong.gitgrass.** {
@androidx.compose.runtime.Composable <methods>;
}

# @Immutable 어노테이션 클래스 보호 (Compose 안정성 추론)
-keep @androidx.compose.runtime.Immutable class * { *; }
# @Immutable 어노테이션 클래스 보호 (Compose 안정성 추론, 라이브러리 패키지로 한정)
-keep @androidx.compose.runtime.Immutable class com.inseong.gitgrass.** { *; }
28 changes: 21 additions & 7 deletions library/src/androidTest/java/com/inseong/gitgrass/GrassCellTest.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.inseong.gitgrass

import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.longClick
import androidx.compose.ui.test.onNodeWithContentDescription
Expand All @@ -24,9 +23,7 @@ class GrassCellTest {

composeTestRule.setContent {
GrassCell(
color = GitGrassDefaults.colors().levels[0],
size = GitGrassDefaults.cellSize,
shape = RoundedCornerShape(GitGrassDefaults.cellCornerRadius),
contentDescriptionText = "$date: $count",
clickLabelText = "$date details",
onClick = { clicked = true },
Expand All @@ -47,9 +44,7 @@ class GrassCellTest {

composeTestRule.setContent {
GrassCell(
color = GitGrassDefaults.colors().levels[0],
size = GitGrassDefaults.cellSize,
shape = RoundedCornerShape(GitGrassDefaults.cellCornerRadius),
contentDescriptionText = "$date: $count",
clickLabelText = "$date details",
onClick = {},
Expand All @@ -72,9 +67,7 @@ class GrassCellTest {

composeTestRule.setContent {
GrassCell(
color = GitGrassDefaults.colors().empty,
size = GitGrassDefaults.cellSize,
shape = RoundedCornerShape(GitGrassDefaults.cellCornerRadius),
contentDescriptionText = "$date: $count",
clickLabelText = "$date details",
onClick = null,
Expand All @@ -84,4 +77,25 @@ class GrassCellTest {
composeTestRule.onNodeWithContentDescription("$date: $count")
.assertExists()
}

@Test
fun gitGrass_canvasBackedCells_keepAccessibilitySemantics() {
val date = LocalDate.of(2025, 6, 15)
val count = 7

composeTestRule.setContent {
GitGrass(
contributions = mapOf(date to count),
startDate = date,
endDate = date,
showYearLabel = false,
showWeekLabels = false,
showMonthLabels = false,
showLegend = false,
)
}

composeTestRule.onNodeWithContentDescription("$date: $count")
.assertExists()
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.inseong.gitgrass

import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
Expand Down Expand Up @@ -31,7 +31,7 @@ class LabelRenderingTest {
cellSpacing = GitGrassDefaults.cellSpacing,
fontSize = GitGrassDefaults.labelFontSize,
textColor = GitGrassDefaults.colors().text,
scrollState = ScrollState(0),
scrollState = rememberLazyListState(),
weekLabelWidth = GitGrassDefaults.weekLabelWidth,
)
}
Expand Down
33 changes: 27 additions & 6 deletions library/src/main/java/com/inseong/gitgrass/GitGrass.kt
Original file line number Diff line number Diff line change
@@ -1,27 +1,30 @@
package com.inseong.gitgrass

import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import java.time.DayOfWeek
import java.time.LocalDate
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first

/**
* Streak information calculated from contribution data.
Expand Down Expand Up @@ -145,10 +148,28 @@ fun GitGrass(
)
}

val scrollState: ScrollState = rememberScrollState()
val scrollState = rememberLazyListState()
val density = LocalDensity.current

LaunchedEffect(grid) {
scrollState.scrollTo(scrollState.maxValue)
LaunchedEffect(safeStart, safeEnd, renderGrid.size, cellSize, cellSpacing, density) {
if (renderGrid.isEmpty()) return@LaunchedEffect

val viewportWidth = snapshotFlow { scrollState.layoutInfo.viewportSize.width }
.filter { it > 0 }
.first()
val cellSizePx = with(density) { cellSize.roundToPx() }
val cellSpacingPx = with(density) { cellSpacing.roundToPx() }
val totalGridWidth =
(renderGrid.size * cellSizePx) + ((renderGrid.size - 1).coerceAtLeast(0) * cellSpacingPx)

if (totalGridWidth > viewportWidth) {
scrollState.scrollToItem(
index = renderGrid.lastIndex,
scrollOffset = cellSizePx - viewportWidth,
)
} else {
scrollState.scrollToItem(0)
}
}

val weekLabelWidth = if (showWeekLabels) GitGrassDefaults.weekLabelWidth else 0.dp
Expand Down Expand Up @@ -198,7 +219,7 @@ fun GitGrass(
renderGrid = renderGrid,
cellSize = cellSize,
cellSpacing = cellSpacing,
cellShape = cellShape,
cellCornerRadius = cellCornerRadius,
scrollState = scrollState,
onCellClick = onCellClick,
onCellLongClick = onCellLongClick,
Expand Down
Loading