Skip to content

Repository files navigation

Reading Diary - Chrome Extension

Chrome Extension giúp ghi lại nhật ký đọc (email, news, blog, social media posts) với phím tắt nhanh.

Features

  • 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

Tech Stack (01/2026)

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+

Data Storage

Sử dụng chrome.storage.local (10MB limit).

Data Schema

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[];
}

Project Structure

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

Setup

Prerequisites

  • Node.js 20+
  • npm 10+

Installation

# 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

Load Extension (Development)

  1. Mở chrome://extensions/
  2. Bật "Developer mode"
  3. Click "Load unpacked"
  4. Chọn thư mục dist/

TDD Workflow (Red-Green-Refactor)

Quy trình

┌──────────────────────────────────────────────────────────┐
│  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                   │
└──────────────────────────────────────────────────────────┘

Testing Pyramid

          /\
         /  \      E2E Tests (Playwright)
        /    \     - Full extension flow
       /──────\
      /        \   Integration Tests
     /          \  - Store + Chrome Storage
    /────────────\
   /              \ Unit Tests (Vitest)
  /                \ - Pure functions, store logic
 ──────────────────

Ví dụ TDD: Add Entry Feature

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


Chrome Extension Permissions

manifest.json

{
  "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"
  }
}

Permissions Explained

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)

Reminder Mechanism

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'
  });
});

Test Configuration

vitest.config.ts

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'),
    },
  },
});

tests/setup.ts

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

{
  "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"
  }
}

License

MIT

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages