Chrome Extension giúp ghi lại nhật ký đọc (email, news, blog, social media posts) với phím tắt nhanh.
- Phím tắt mở nhanh - Mở extension bằng keyboard shortcut
- Ghi nhật ký - Tiêu đề, thể loại, nội dung, tags, date, reminder
- Quản lý - Xóa, lọc theo thể loại, tìm kiếm theo tags
- Tìm kiếm - Theo khoảng thời gian
- Nhắc nhở - Badge + Alarms notification
| Layer | Technology | Version |
|---|---|---|
| Language | TypeScript | 5.5+ |
| UI Framework | React | 19 |
| Build Tool | Vite + CRXJS | 6.x |
| Styling | Tailwind CSS | 4.0 |
| State Management | Zustand | 5.x |
| Unit/Integration Test | Vitest | 3.x |
| Component Test | React Testing Library | 16.x |
| E2E Test | Playwright | 1.49+ |
Sử dụng chrome.storage.local (10MB limit).
interface DiaryEntry {
id: string; // UUID
title: string;
category: string; // 'email' | 'news' | 'blog' | 'social' | 'other'
content: string;
tags: string[];
sourceUrl?: string; // URL trang đang đọc
createdAt: number; // timestamp
reminder?: number; // timestamp (optional)
}
interface DiaryStore {
entries: DiaryEntry[];
categories: string[];
}reading-diary/
├── src/
│ ├── popup/ # Popup UI
│ │ ├── components/
│ │ │ ├── EntryForm.tsx
│ │ │ ├── EntryList.tsx
│ │ │ ├── SearchBar.tsx
│ │ │ └── FilterPanel.tsx
│ │ ├── hooks/
│ │ │ └── useEntries.ts
│ │ ├── Popup.tsx
│ │ └── main.tsx
│ ├── background/ # Service worker
│ │ └── index.ts
│ ├── store/ # Zustand store
│ │ └── diaryStore.ts
│ ├── types/
│ │ └── diary.ts
│ └── utils/
│ ├── storage.ts
│ └── search.ts
├── tests/
│ ├── unit/ # Unit tests
│ │ ├── store.test.ts
│ │ └── utils.test.ts
│ ├── integration/ # Integration tests
│ │ └── storage.test.ts
│ └── e2e/ # E2E tests
│ └── popup.spec.ts
├── public/
│ ├── manifest.json
│ └── icons/
├── vitest.config.ts
├── vite.config.ts
├── playwright.config.ts
├── tailwind.config.ts
├── tsconfig.json
└── package.json
- Node.js 20+
- npm 10+
# Clone & install
cd reading-diary
npm install
# Development
npm run dev
# Testing
npm run test # Unit tests (watch mode)
npm run test:run # Unit tests (single run)
npm run test:coverage # Coverage report
npm run test:e2e # E2E tests
# Build
npm run build- Mở
chrome://extensions/ - Bật "Developer mode"
- Click "Load unpacked"
- Chọn thư mục
dist/
┌──────────────────────────────────────────────────────────┐
│ 1. RED: Viết test FAIL trước │
│ └── Test mô tả behavior mong muốn │
│ │
│ 2. GREEN: Viết code TỐI THIỂU để pass │
│ └── Không tối ưu, chỉ cần pass │
│ │
│ 3. REFACTOR: Cải thiện code, giữ test GREEN │
│ └── Clean code, remove duplication │
└──────────────────────────────────────────────────────────┘
/\
/ \ E2E Tests (Playwright)
/ \ - Full extension flow
/──────\
/ \ Integration Tests
/ \ - Store + Chrome Storage
/────────────\
/ \ Unit Tests (Vitest)
/ \ - Pure functions, store logic
──────────────────
Step 1: RED - Viết test trước
// tests/unit/diaryStore.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { useDiaryStore } from '@/store/diaryStore';
describe('DiaryStore', () => {
beforeEach(() => {
useDiaryStore.getState().reset();
});
it('should add a new diary entry', () => {
const store = useDiaryStore.getState();
store.addEntry({
title: 'Bài viết hay',
category: 'blog',
content: 'Nội dung...',
tags: ['tech', 'ai'],
});
expect(store.entries).toHaveLength(1);
expect(store.entries[0].title).toBe('Bài viết hay');
expect(store.entries[0].id).toBeDefined();
expect(store.entries[0].createdAt).toBeDefined();
});
it('should delete entry by id', () => {
const store = useDiaryStore.getState();
store.addEntry({ title: 'Test', category: 'blog', content: '', tags: [] });
const entryId = store.entries[0].id;
store.deleteEntry(entryId);
expect(store.entries).toHaveLength(0);
});
it('should filter entries by category', () => {
const store = useDiaryStore.getState();
store.addEntry({ title: 'Blog 1', category: 'blog', content: '', tags: [] });
store.addEntry({ title: 'Email 1', category: 'email', content: '', tags: [] });
const filtered = store.getEntriesByCategory('blog');
expect(filtered).toHaveLength(1);
expect(filtered[0].title).toBe('Blog 1');
});
it('should search entries by tags', () => {
const store = useDiaryStore.getState();
store.addEntry({ title: 'AI Article', category: 'blog', content: '', tags: ['ai', 'tech'] });
store.addEntry({ title: 'Recipe', category: 'other', content: '', tags: ['food'] });
const results = store.searchByTags(['ai']);
expect(results).toHaveLength(1);
expect(results[0].title).toBe('AI Article');
});
it('should search entries by date range', () => {
const store = useDiaryStore.getState();
const now = Date.now();
// Manually set createdAt for testing
store.addEntry({ title: 'Old', category: 'blog', content: '', tags: [] });
store.entries[0].createdAt = now - 7 * 24 * 60 * 60 * 1000; // 7 days ago
store.addEntry({ title: 'Recent', category: 'blog', content: '', tags: [] });
const results = store.searchByDateRange(now - 24 * 60 * 60 * 1000, now);
expect(results).toHaveLength(1);
expect(results[0].title).toBe('Recent');
});
});Step 2: GREEN - Implement tối thiểu
// src/store/diaryStore.ts
import { create } from 'zustand';
import type { DiaryEntry } from '@/types/diary';
interface DiaryState {
entries: DiaryEntry[];
addEntry: (entry: Omit<DiaryEntry, 'id' | 'createdAt'>) => void;
deleteEntry: (id: string) => void;
getEntriesByCategory: (category: string) => DiaryEntry[];
searchByTags: (tags: string[]) => DiaryEntry[];
searchByDateRange: (start: number, end: number) => DiaryEntry[];
reset: () => void;
}
export const useDiaryStore = create<DiaryState>((set, get) => ({
entries: [],
addEntry: (entry) => {
set((state) => ({
entries: [...state.entries, {
...entry,
id: crypto.randomUUID(),
createdAt: Date.now(),
}]
}));
},
deleteEntry: (id) => {
set((state) => ({
entries: state.entries.filter(e => e.id !== id)
}));
},
getEntriesByCategory: (category) => {
return get().entries.filter(e => e.category === category);
},
searchByTags: (tags) => {
return get().entries.filter(e =>
tags.some(tag => e.tags.includes(tag))
);
},
searchByDateRange: (start, end) => {
return get().entries.filter(e =>
e.createdAt >= start && e.createdAt <= end
);
},
reset: () => set({ entries: [] }),
}));Step 3: REFACTOR - Thêm persistence với chrome.storage
{
"name": "Reading Diary",
"version": "1.0.0",
"manifest_version": 3,
"description": "Ghi lại nhật ký đọc với phím tắt nhanh",
"permissions": [
"storage",
"alarms",
"notifications",
"activeTab"
],
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"commands": {
"_execute_action": {
"suggested_key": {
"default": "Ctrl+Shift+D",
"mac": "Command+Shift+D"
},
"description": "Mở Reading Diary"
}
},
"background": {
"service_worker": "background.js",
"type": "module"
}
}| Permission | Mục đích | User Concern |
|---|---|---|
storage |
Lưu nhật ký local | Thấp |
alarms |
Đặt lịch nhắc nhở | Thấp |
notifications |
Hiển thị reminder popup | Trung bình |
activeTab |
Lấy URL trang hiện tại | Thấp (chỉ khi user click) |
Badge: Hiển thị số pending reminders trên icon (passive)
chrome.action.setBadgeText({ text: '3' });
chrome.action.setBadgeBackgroundColor({ color: '#EF4444' });Alarms: Chủ động nhắc đúng thời điểm (active)
// Đặt reminder
chrome.alarms.create(`reminder-${entryId}`, {
when: reminderTimestamp
});
// Xử lý khi alarm kích hoạt
chrome.alarms.onAlarm.addListener((alarm) => {
chrome.notifications.create({
type: 'basic',
title: 'Reading Diary',
message: 'Nhắc nhở: Xem lại bài viết...',
iconUrl: 'icons/icon128.png'
});
});import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./tests/setup.ts'],
include: ['tests/**/*.test.ts', 'tests/**/*.test.tsx'],
exclude: ['tests/e2e/**'],
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
include: ['src/**/*.ts', 'src/**/*.tsx'],
exclude: ['src/**/*.d.ts'],
},
},
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
});import '@testing-library/jest-dom/vitest';
import { vi } from 'vitest';
// Mock Chrome APIs
global.chrome = {
storage: {
local: {
get: vi.fn().mockResolvedValue({}),
set: vi.fn().mockResolvedValue(undefined),
},
},
alarms: {
create: vi.fn(),
clear: vi.fn(),
onAlarm: {
addListener: vi.fn(),
},
},
action: {
setBadgeText: vi.fn(),
setBadgeBackgroundColor: vi.fn(),
},
notifications: {
create: vi.fn(),
},
} as unknown as typeof chrome;{
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"lint": "eslint src --ext ts,tsx",
"type-check": "tsc --noEmit"
}
}MIT