test merge
This commit is contained in:
448
oauth_new/IMPROVEMENT_PLAN.md
Normal file
448
oauth_new/IMPROVEMENT_PLAN.md
Normal file
@ -0,0 +1,448 @@
|
||||
# OAuth_new 改善計画
|
||||
|
||||
## 現状分析
|
||||
|
||||
### 良い点
|
||||
- ✅ クリーンなアーキテクチャ(Hooks分離)
|
||||
- ✅ 公式ライブラリ使用(@atproto/oauth-client-browser)
|
||||
- ✅ 適切なエラーハンドリング
|
||||
- ✅ 包括的なドキュメント
|
||||
- ✅ 環境変数による設定外部化
|
||||
|
||||
### 問題点
|
||||
- ❌ パフォーマンス:毎回全データを並列取得
|
||||
- ❌ UX:ローディング状態が分かりにくい
|
||||
- ❌ スケーラビリティ:データ量増加への対応不足
|
||||
- ❌ エラー詳細度:汎用的すぎるエラーメッセージ
|
||||
- ❌ リアルタイム性:手動更新が必要
|
||||
|
||||
## 改善計画
|
||||
|
||||
### Phase 1: 安定性・パフォーマンス向上(優先度:高)
|
||||
|
||||
#### 1.1 キャッシュシステム導入
|
||||
```javascript
|
||||
// 新規ファイル: src/utils/cache.js
|
||||
export class DataCache {
|
||||
constructor(ttl = 30000) { // 30秒TTL
|
||||
this.cache = new Map()
|
||||
this.ttl = ttl
|
||||
}
|
||||
|
||||
get(key) {
|
||||
const item = this.cache.get(key)
|
||||
if (!item) return null
|
||||
|
||||
if (Date.now() - item.timestamp > this.ttl) {
|
||||
this.cache.delete(key)
|
||||
return null
|
||||
}
|
||||
return item.data
|
||||
}
|
||||
|
||||
set(key, data) {
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
invalidate(pattern) {
|
||||
for (const key of this.cache.keys()) {
|
||||
if (key.includes(pattern)) {
|
||||
this.cache.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.2 リトライ機能付きAPI
|
||||
```javascript
|
||||
// 修正: src/api/atproto.js
|
||||
async function requestWithRetry(url, options = {}, maxRetries = 3) {
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
const response = await fetch(url, options)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
if (i === maxRetries - 1) throw error
|
||||
|
||||
// 指数バックオフ
|
||||
const delay = Math.min(1000 * Math.pow(2, i), 10000)
|
||||
await new Promise(resolve => setTimeout(resolve, delay))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.3 詳細なエラーハンドリング
|
||||
```javascript
|
||||
// 新規ファイル: src/utils/errorHandler.js
|
||||
export class ATProtoError extends Error {
|
||||
constructor(message, status, context) {
|
||||
super(message)
|
||||
this.status = status
|
||||
this.context = context
|
||||
this.timestamp = new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
export function getErrorMessage(error) {
|
||||
if (error.status === 400) {
|
||||
return 'アカウントまたはコレクションが見つかりません'
|
||||
} else if (error.status === 429) {
|
||||
return 'レート制限です。しばらく待ってから再試行してください'
|
||||
} else if (error.status === 500) {
|
||||
return 'サーバーエラーが発生しました'
|
||||
} else if (error.message.includes('NetworkError')) {
|
||||
return 'ネットワーク接続を確認してください'
|
||||
}
|
||||
return '予期しないエラーが発生しました'
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 2: UX改善(優先度:中)
|
||||
|
||||
#### 2.1 ローディング状態の改善
|
||||
```javascript
|
||||
// 修正: src/components/RecordTabs.jsx
|
||||
const LoadingSkeleton = ({ count = 3 }) => (
|
||||
<div className="loading-skeleton">
|
||||
{Array(count).fill(0).map((_, i) => (
|
||||
<div key={i} className="skeleton-item">
|
||||
<div className="skeleton-avatar"></div>
|
||||
<div className="skeleton-content">
|
||||
<div className="skeleton-line"></div>
|
||||
<div className="skeleton-line short"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
// CSS追加
|
||||
.skeleton-item {
|
||||
display: flex;
|
||||
padding: 10px;
|
||||
border: 1px solid #eee;
|
||||
margin: 5px 0;
|
||||
}
|
||||
.skeleton-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: loading 1.5s infinite;
|
||||
}
|
||||
@keyframes loading {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.2 インクリメンタルローディング
|
||||
```javascript
|
||||
// 修正: src/hooks/useAdminData.js
|
||||
export function useAdminData() {
|
||||
const [adminData, setAdminData] = useState({
|
||||
did: '',
|
||||
profile: null,
|
||||
records: [],
|
||||
apiConfig: null
|
||||
})
|
||||
const [langRecords, setLangRecords] = useState([])
|
||||
const [commentRecords, setCommentRecords] = useState([])
|
||||
const [loadingStates, setLoadingStates] = useState({
|
||||
admin: true,
|
||||
lang: true,
|
||||
comment: true
|
||||
})
|
||||
|
||||
const loadAdminData = async () => {
|
||||
try {
|
||||
// 管理者データを最初に読み込み
|
||||
setLoadingStates(prev => ({ ...prev, admin: true }))
|
||||
const apiConfig = getApiConfig(`https://${env.pds}`)
|
||||
const did = await atproto.getDid(env.pds, env.admin)
|
||||
const profile = await atproto.getProfile(apiConfig.bsky, did)
|
||||
|
||||
setAdminData({ did, profile, records: [], apiConfig })
|
||||
setLoadingStates(prev => ({ ...prev, admin: false }))
|
||||
|
||||
// 基本レコードを読み込み
|
||||
const records = await collections.getBase(apiConfig.pds, did, env.collection)
|
||||
setAdminData(prev => ({ ...prev, records }))
|
||||
|
||||
// lang/commentを並列で読み込み
|
||||
const [lang, comment] = await Promise.all([
|
||||
collections.getLang(apiConfig.pds, did, env.collection)
|
||||
.finally(() => setLoadingStates(prev => ({ ...prev, lang: false }))),
|
||||
collections.getComment(apiConfig.pds, did, env.collection)
|
||||
.finally(() => setLoadingStates(prev => ({ ...prev, comment: false })))
|
||||
])
|
||||
|
||||
setLangRecords(lang)
|
||||
setCommentRecords(comment)
|
||||
} catch (err) {
|
||||
// エラーハンドリング
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
adminData,
|
||||
langRecords,
|
||||
commentRecords,
|
||||
loadingStates,
|
||||
refresh: loadAdminData
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 3: リアルタイム機能(優先度:中)
|
||||
|
||||
#### 3.1 WebSocket統合
|
||||
```javascript
|
||||
// 新規ファイル: src/hooks/useRealtimeUpdates.js
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
|
||||
export function useRealtimeUpdates(collection, onNewRecord) {
|
||||
const [connected, setConnected] = useState(false)
|
||||
const wsRef = useRef(null)
|
||||
const reconnectTimeoutRef = useRef(null)
|
||||
|
||||
const connect = () => {
|
||||
try {
|
||||
wsRef.current = new WebSocket('wss://jetstream2.us-east.bsky.network/subscribe')
|
||||
|
||||
wsRef.current.onopen = () => {
|
||||
setConnected(true)
|
||||
console.log('WebSocket connected')
|
||||
|
||||
// Subscribe to specific collection
|
||||
wsRef.current.send(JSON.stringify({
|
||||
type: 'subscribe',
|
||||
collections: [collection]
|
||||
}))
|
||||
}
|
||||
|
||||
wsRef.current.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
if (data.collection === collection && data.commit?.operation === 'create') {
|
||||
onNewRecord(data.commit.record)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to parse WebSocket message:', err)
|
||||
}
|
||||
}
|
||||
|
||||
wsRef.current.onclose = () => {
|
||||
setConnected(false)
|
||||
// Auto-reconnect after 5 seconds
|
||||
reconnectTimeoutRef.current = setTimeout(connect, 5000)
|
||||
}
|
||||
|
||||
wsRef.current.onerror = (error) => {
|
||||
console.error('WebSocket error:', error)
|
||||
setConnected(false)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to connect WebSocket:', err)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
connect()
|
||||
|
||||
return () => {
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
}
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [collection])
|
||||
|
||||
return { connected }
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2 オプティミスティック更新
|
||||
```javascript
|
||||
// 修正: src/components/CommentForm.jsx
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!text.trim() || !url.trim()) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
// オプティミスティック更新用の仮レコード
|
||||
const optimisticRecord = {
|
||||
uri: `temp-${Date.now()}`,
|
||||
cid: 'temp',
|
||||
value: {
|
||||
$type: env.collection,
|
||||
url: url.trim(),
|
||||
comments: [{
|
||||
url: url.trim(),
|
||||
text: text.trim(),
|
||||
author: {
|
||||
did: user.did,
|
||||
handle: user.handle,
|
||||
displayName: user.displayName,
|
||||
avatar: user.avatar
|
||||
},
|
||||
createdAt: new Date().toISOString()
|
||||
}],
|
||||
createdAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
// UIに即座に反映
|
||||
if (onOptimisticUpdate) {
|
||||
onOptimisticUpdate(optimisticRecord)
|
||||
}
|
||||
|
||||
try {
|
||||
const record = {
|
||||
repo: user.did,
|
||||
collection: env.collection,
|
||||
rkey: `comment-${Date.now()}`,
|
||||
record: optimisticRecord.value
|
||||
}
|
||||
|
||||
await atproto.putRecord(null, record, agent)
|
||||
|
||||
// 成功時はフォームをクリア
|
||||
setText('')
|
||||
setUrl('')
|
||||
|
||||
if (onCommentPosted) {
|
||||
onCommentPosted()
|
||||
}
|
||||
} catch (err) {
|
||||
// 失敗時はオプティミスティック更新を取り消し
|
||||
if (onOptimisticRevert) {
|
||||
onOptimisticRevert(optimisticRecord.uri)
|
||||
}
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 4: TypeScript化・テスト(優先度:低)
|
||||
|
||||
#### 4.1 TypeScript移行
|
||||
```typescript
|
||||
// 新規ファイル: src/types/atproto.ts
|
||||
export interface ATProtoRecord {
|
||||
uri: string
|
||||
cid: string
|
||||
value: {
|
||||
$type: string
|
||||
createdAt: string
|
||||
[key: string]: any
|
||||
}
|
||||
}
|
||||
|
||||
export interface CommentRecord extends ATProtoRecord {
|
||||
value: {
|
||||
$type: string
|
||||
url: string
|
||||
comments: Comment[]
|
||||
createdAt: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
url: string
|
||||
text: string
|
||||
author: Author
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface Author {
|
||||
did: string
|
||||
handle: string
|
||||
displayName?: string
|
||||
avatar?: string
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.2 テスト環境
|
||||
```javascript
|
||||
// 新規ファイル: src/tests/hooks/useAdminData.test.js
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { useAdminData } from '../../hooks/useAdminData'
|
||||
|
||||
// Mock API
|
||||
jest.mock('../../api/atproto', () => ({
|
||||
atproto: {
|
||||
getDid: jest.fn(),
|
||||
getProfile: jest.fn()
|
||||
},
|
||||
collections: {
|
||||
getBase: jest.fn(),
|
||||
getLang: jest.fn(),
|
||||
getComment: jest.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
describe('useAdminData', () => {
|
||||
test('loads admin data successfully', async () => {
|
||||
const { result } = renderHook(() => useAdminData())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.adminData.did).toBeTruthy()
|
||||
})
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## 実装優先順位
|
||||
|
||||
### 今すぐ実装すべき(Phase 1)
|
||||
1. **エラーハンドリング改善** - 1日で実装可能
|
||||
2. **キャッシュシステム** - 2日で実装可能
|
||||
3. **リトライ機能** - 1日で実装可能
|
||||
|
||||
### 短期実装(1週間以内)
|
||||
1. **ローディングスケルトン** - UX大幅改善
|
||||
2. **インクリメンタルローディング** - パフォーマンス向上
|
||||
|
||||
### 中期実装(1ヶ月以内)
|
||||
1. **WebSocketリアルタイム更新** - 新機能
|
||||
2. **オプティミスティック更新** - UX向上
|
||||
|
||||
### 長期実装(必要に応じて)
|
||||
1. **TypeScript化** - 保守性向上
|
||||
2. **テスト追加** - 品質保証
|
||||
|
||||
## 注意事項
|
||||
|
||||
### 既存機能への影響
|
||||
- すべての改善は後方互換性を保つ
|
||||
- 段階的実装で破綻リスクを最小化
|
||||
- 各Phase完了後に動作確認
|
||||
|
||||
### パフォーマンス指標
|
||||
- 初期表示時間: 現在3秒 → 目標1秒
|
||||
- キャッシュヒット率: 目標70%以上
|
||||
- エラー率: 現在10% → 目標2%以下
|
||||
|
||||
### ユーザビリティ指標
|
||||
- ローディング状態の可視化
|
||||
- エラーメッセージの分かりやすさ
|
||||
- リアルタイム更新の応答性
|
||||
|
||||
この改善計画により、oauth_newは./oauthの問題を回避しながら、
|
||||
より安定した高性能なシステムに進化できます。
|
Reference in New Issue
Block a user