├── .gitignore ├── README.md ├── src ├── types │ ├── Deck.ts │ └── Database.ts ├── components │ ├── UiToggleSwitch.vue │ ├── UiTextInput.vue │ ├── FloatingButton.vue │ ├── ActionSheet.vue │ ├── CustomStat.vue │ ├── NativeStats.vue │ ├── DropdownMenu.vue │ ├── CharacterGrid.vue │ ├── FloatingMenuButton.vue │ ├── ReviewHistory.vue │ ├── ReviewIntervals.vue │ ├── CardsDue.vue │ ├── WordCount.vue │ ├── TimeChart.vue │ ├── CharacterStats.vue │ └── WordHistory.vue ├── utils │ ├── theme.ts │ ├── constants.ts │ ├── observers.ts │ ├── logger.ts │ ├── kanjiDatabase.ts │ └── sql-queries.ts ├── stores │ ├── app.ts │ ├── intervalStats.ts │ ├── dueStats.ts │ ├── wordStats.ts │ ├── characterStats.ts │ ├── reviewHistory.ts │ ├── studyStats.ts │ ├── timeStats.ts │ ├── wordHistory.ts │ └── cards.ts └── main.ts ├── tsconfig.json ├── .vscode └── settings.json ├── tsconfig.node.json ├── tsconfig.app.json ├── package.json ├── .github └── workflows │ └── release.yml ├── vite.config.ts └── .gitattributes /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /dist 3 | /build -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Migaku Stats 2 | 3 | A simple project to track Migaku statistics. 4 | -------------------------------------------------------------------------------- /src/types/Deck.ts: -------------------------------------------------------------------------------- 1 | export interface Deck { 2 | id: string; 3 | name: string; 4 | lang: string; 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[javascript]": { 3 | "editor.tabSize": 2 4 | }, 5 | "[javascriptreact]": { 6 | "editor.tabSize": 2 7 | }, 8 | "[typescript]": { 9 | "editor.tabSize": 2 10 | }, 11 | "[typescriptreact]": { 12 | "editor.tabSize": 2 13 | }, 14 | "[vue]": { 15 | "editor.tabSize": 2 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": ["vite.config.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /src/components/UiToggleSwitch.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 29 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "module": "ESNext", 7 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "isolatedModules": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | "jsx": "preserve", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedSideEffectImports": true 24 | }, 25 | "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] 26 | } 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "migaku-more-stats", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vue-tsc -b && vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "@types/pako": "^2.0.4", 13 | "grid-layout-plus": "^1.1.1", 14 | "pako": "^2.1.0", 15 | "pinia": "^3.0.3", 16 | "sql.js": "^1.13.0", 17 | "vue": "^3.5.19", 18 | "vue-chartjs": "^5.3.2", 19 | "vuedraggable": "^4.1.0" 20 | }, 21 | "devDependencies": { 22 | "@types/chart.js": "^4.0.1", 23 | "@types/sql.js": "^1.4.9", 24 | "@vitejs/plugin-vue": "^6.0.1", 25 | "sass": "^1.93.2", 26 | "typescript": "^5.9.2", 27 | "vite": "^7.1.3", 28 | "vite-plugin-monkey": "^7.1.1", 29 | "vue-tsc": "^3.0.6" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: write 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | 19 | - name: Setup Bun 20 | uses: oven-sh/setup-bun@v2 21 | with: 22 | bun-version: latest 23 | 24 | - name: Install dependencies 25 | run: bun install --frozen-lockfile 26 | 27 | - name: Build 28 | run: bun run build 29 | 30 | - name: Upload build artifact 31 | uses: actions/upload-artifact@v4 32 | with: 33 | name: userscript 34 | path: dist/migaku-more-stats.user.js 35 | 36 | - name: Create GitHub Release (tags only) 37 | if: startsWith(github.ref, 'refs/tags/v') 38 | uses: softprops/action-gh-release@v2 39 | with: 40 | name: ${{ github.ref_name }} 41 | tag_name: ${{ github.ref_name }} 42 | generate_release_notes: true 43 | files: | 44 | dist/migaku-more-stats.user.js 45 | -------------------------------------------------------------------------------- /src/utils/theme.ts: -------------------------------------------------------------------------------- 1 | export const THEME_CONFIGS = { 2 | DARK: { 3 | backgroundElevation1: "#202047", 4 | backgroundElevation2: "#2b2b60", 5 | accent1: "rgba(178, 114, 255, 1)", 6 | accent2: "#fe4670", 7 | accent3: "#fba335", 8 | accent1Transparent: "rgba(178, 114, 255, 0.12)", 9 | textColor: "rgba(255, 255, 255, 1)", 10 | gridColor: "rgba(255, 255, 255, 0.1)", 11 | knownColor: "rgba(0, 199, 164, 1)", 12 | learningColor: "rgba(0, 199, 164, 0.4)", 13 | unknownColor: "rgba(255, 255, 255, 0.12)", 14 | ignoredColor: "rgba(255, 255, 255, 0.35)", 15 | barColor: "rgba(0, 199, 164, 1)", 16 | }, 17 | LIGHT: { 18 | backgroundElevation1: "#fff", 19 | backgroundElevation2: "#fff", 20 | accent1: "#672fc3", 21 | accent2: "#fe4670", 22 | accent3: "#ff9345", 23 | accent1Transparent: "rgba(103, 47, 195, 0.12)", 24 | textColor: "rgba(0, 0, 90, 1)", 25 | gridColor: "rgba(0, 0, 0, 0.1)", 26 | knownColor: "rgba(0, 199, 164, 1)", 27 | learningColor: "rgba(0, 199, 164, 0.4)", 28 | unknownColor: "rgba(0, 0, 90, 0.07)", 29 | ignoredColor: "rgba(0, 0, 90, 0.15)", 30 | barColor: "rgba(0, 199, 164, 1)", 31 | }, 32 | }; -------------------------------------------------------------------------------- /src/components/UiTextInput.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 43 | -------------------------------------------------------------------------------- /src/components/FloatingButton.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 43 | 44 | 50 | -------------------------------------------------------------------------------- /src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | export const ATTRIBUTES = { 2 | LANG_SELECTED: "data-mgk-lang-selected", 3 | THEME: "data-mgk-theme", 4 | } as const; 5 | 6 | export const APP_SETTINGS = { 7 | ENVIRONMENT: "prod", 8 | DEFAULT_TIMEOUT: 15000, 9 | DEFAULT_DECK_ID: "all", 10 | } as const; 11 | 12 | export const SELECTORS = { 13 | STATISTICS_ELEMENT: ".Statistic", 14 | TARGET_ELEMENT: ".UiPageLayout", 15 | MIGAKU_MAIN: ".MIGAKU-SRS", 16 | VUE_CONTAINER_ID: "migaku-custom-stats-vue-container", 17 | ERROR_CONTAINER_ID: "migaku-custom-stats-error", 18 | HEATMAP: ".Statistic__heatmap" 19 | } as const; 20 | 21 | export const ROUTES = { 22 | STATS_ROUTE: "/statistic", 23 | } as const; 24 | 25 | export const WORD_STATUS = { 26 | KNOWN: "KNOWN", 27 | LEARNING: "LEARNING", 28 | UNKNOWN: "UNKNOWN", 29 | IGNORED: "IGNORED", 30 | } as const; 31 | 32 | export const DB_CONFIG = { 33 | DB_NAME: "srs", 34 | OBJECT_STORE: "data" 35 | } as const; 36 | 37 | export const CHART_CONFIG = { 38 | FORECAST_DAYS: 30, 39 | START_YEAR: 2020, 40 | START_MONTH: 0, 41 | START_DAY: 1, 42 | CHART_LABELS: { 43 | KNOWN: "Known", 44 | LEARNING: "Learning", 45 | UNKNOWN: "Unknown", 46 | IGNORED: "Ignored" 47 | }, 48 | TOOLTIP_CONFIG: { 49 | CORNER_RADIUS: 20, 50 | PADDING: 12, 51 | CARET_SIZE: 0, 52 | BOX_PADDING: 4, 53 | }, 54 | ANIMATION_DELAY: 250 55 | } as const; 56 | 57 | export const CHARACTER_STATS = { 58 | CHARACTER_REGEX: /\p{Unified_Ideograph}/u, 59 | CHARACTER_STATUS: { 60 | KNOWN: "KNOWN", 61 | LEARNING: "LEARNING", 62 | UNKNOWN: "UNKNOWN", 63 | }, 64 | } as const; -------------------------------------------------------------------------------- /src/types/Database.ts: -------------------------------------------------------------------------------- 1 | export interface WordStats { 2 | known_count: number; 3 | learning_count: number; 4 | unknown_count: number; 5 | ignored_count: number; 6 | } 7 | 8 | export interface DueStats { 9 | labels: string[]; 10 | counts: number[]; 11 | knownCounts?: number[]; 12 | learningCounts?: number[]; 13 | } 14 | 15 | export interface IntervalStats { 16 | labels: string[]; 17 | counts: number[]; 18 | } 19 | 20 | export interface StudyStats { 21 | days_studied: number; 22 | days_studied_percent: number; 23 | total_reviews: number; 24 | avg_reviews_per_calendar_day: number; 25 | period_days: number; 26 | pass_rate: number; 27 | new_cards_per_day: number; 28 | total_new_cards: number; 29 | total_cards_added: number; 30 | cards_added_per_day: number; 31 | total_cards_learned: number; 32 | cards_learned_per_day: number; 33 | total_time_new_cards_seconds: number; 34 | avg_time_new_card_seconds: number; 35 | total_time_reviews_seconds: number; 36 | avg_time_review_seconds: number; 37 | } 38 | 39 | export interface ReviewHistoryResult { 40 | labels: string[]; 41 | counts: number[][]; 42 | typeLabels: string[]; 43 | } 44 | 45 | export interface TimeHistoryResult { 46 | labels: string[]; 47 | newCardsTime: number[]; 48 | reviewsTime: number[]; 49 | } 50 | 51 | export interface WordHistoryResult { 52 | labels: string[]; 53 | knownCounts: number[]; 54 | } 55 | 56 | export interface CharacterStats { 57 | knownCharacters: string[]; 58 | learningCharacters: string[]; 59 | } 60 | 61 | export interface KanjiMetadata { 62 | character: string; 63 | level: number; 64 | } 65 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import vue from '@vitejs/plugin-vue'; 3 | import monkey, { cdn, util } from 'vite-plugin-monkey'; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [ 8 | vue(), 9 | monkey({ 10 | entry: 'src/main.ts', 11 | userscript: { 12 | name: 'Migaku Custom Stats', 13 | icon: 'https://study.migaku.com/favicon.ico', 14 | namespace: 'http://tampermonkey.net/', 15 | match: ['https://study.migaku.com/*'], 16 | version: '0.2.8', 17 | description: 'More stats for Migaku Memory.', 18 | author: 'sguadalupe', 19 | license: 'GPL-3.0', 20 | 'run-at': 'document-idle', 21 | supportURL: 'https://github.com/SebastianGuadalupe/MigakuStats/issues', 22 | homepageURL: 'https://github.com/SebastianGuadalupe/MigakuStats', 23 | connect: ['github.com', 'raw.githubusercontent.com'], 24 | }, 25 | clientAlias: 'monkey', 26 | build: { 27 | externalGlobals: [ 28 | [ 29 | 'vue', 30 | cdn 31 | .jsdelivr('Vue', 'dist/vue.global.prod.js') 32 | .concat(util.dataUrl(';window.Vue=Vue;')), 33 | ], 34 | ['pinia', cdn.jsdelivr('Pinia', 'dist/pinia.iife.prod.js')], 35 | [ 36 | 'chart.js', 37 | cdn.jsdelivr('Chart', 'dist/chart.umd.min.js'), 38 | ], 39 | ['sql.js', cdn.jsdelivr('initSqlJs', 'dist/sql-wasm.min.js')], 40 | ['pako', cdn.jsdelivr('pako', 'dist/pako.min.js')], 41 | ], 42 | externalResource: {}, 43 | }, 44 | }), 45 | ], 46 | }); 47 | -------------------------------------------------------------------------------- /src/components/ActionSheet.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 67 | 68 | -------------------------------------------------------------------------------- /src/stores/app.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia'; 2 | import { ref, watch } from 'vue'; 3 | import { APP_SETTINGS } from '../utils/constants'; 4 | import { Deck } from '../types/Deck'; 5 | 6 | const STORAGE_KEY = 'migaku-app'; 7 | 8 | export const useAppStore = defineStore('app', () => { 9 | const language = ref(null); 10 | const theme = ref(null); 11 | const availableDecks = ref([{ id: APP_SETTINGS.DEFAULT_DECK_ID, name: 'All decks', lang: 'all' }]); 12 | const selectedDeckId = ref(APP_SETTINGS.DEFAULT_DECK_ID); 13 | const componentHash = ref(null); 14 | 15 | function loadFromStorage() { 16 | try { 17 | const stored = localStorage.getItem(STORAGE_KEY); 18 | if (stored) { 19 | const data = JSON.parse(stored); 20 | if (data.language) language.value = data.language; 21 | if (data.theme) theme.value = data.theme; 22 | if (data.selectedDeckId) selectedDeckId.value = data.selectedDeckId; 23 | if (data.componentHash) componentHash.value = data.componentHash; 24 | } 25 | } catch (error) { 26 | console.error('Failed to load app state from localStorage:', error); 27 | } 28 | } 29 | 30 | function saveToStorage() { 31 | try { 32 | const data = { 33 | language: language.value, 34 | theme: theme.value, 35 | selectedDeckId: selectedDeckId.value, 36 | componentHash: componentHash.value, 37 | }; 38 | localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); 39 | } catch (error) { 40 | console.error('Failed to save app state to localStorage:', error); 41 | } 42 | } 43 | 44 | watch([language, theme, selectedDeckId, componentHash], saveToStorage); 45 | 46 | function setLanguage(newLanguage: string | null) { language.value = newLanguage; } 47 | function setTheme(newTheme: string | null) { theme.value = newTheme; } 48 | function setSelectedDeckId(newDeckId: string) { selectedDeckId.value = newDeckId; } 49 | function setAvailableDecks(newAvailableDecks: Deck[]) { availableDecks.value = newAvailableDecks; } 50 | function setComponentHash(hash: string) { componentHash.value = hash; } 51 | function resetDeckSelection() { selectedDeckId.value = APP_SETTINGS.DEFAULT_DECK_ID; } 52 | 53 | return { 54 | language, 55 | theme, 56 | selectedDeckId, 57 | availableDecks, 58 | componentHash, 59 | setLanguage, 60 | setTheme, 61 | setSelectedDeckId, 62 | setAvailableDecks, 63 | setComponentHash, 64 | resetDeckSelection, 65 | loadFromStorage 66 | }; 67 | }); 68 | -------------------------------------------------------------------------------- /src/components/CustomStat.vue: -------------------------------------------------------------------------------- 1 | 79 | 80 | 83 | 84 | 93 | -------------------------------------------------------------------------------- /src/stores/intervalStats.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia'; 2 | import { ref, watch } from 'vue'; 3 | import { fetchIntervalStats, reloadDatabase } from '../utils/database'; 4 | import type { IntervalStats } from '../types/Database'; 5 | 6 | const STORAGE_KEY = 'migaku-intervalStats'; 7 | const SETTINGS_KEY = 'migaku-intervalStats-settings'; 8 | 9 | export type PercentileId = '50th' | '75th' | '95th' | '100th'; 10 | 11 | export const useIntervalStatsStore = defineStore('intervalStats', () => { 12 | const intervalStats = ref(null); 13 | const isLoading = ref(false); 14 | const error = ref(''); 15 | const percentileId = ref('75th'); 16 | 17 | function loadSettingsFromStorage() { 18 | try { 19 | const data = localStorage.getItem(SETTINGS_KEY); 20 | if (data) { 21 | const parsed = JSON.parse(data); 22 | if (['50th','75th','95th','100th'].includes(parsed.percentileId)) percentileId.value = parsed.percentileId; 23 | } 24 | } catch {} 25 | } 26 | function saveSettingsToStorage() { 27 | localStorage.setItem(SETTINGS_KEY, JSON.stringify({ percentileId: percentileId.value })); 28 | } 29 | function setPercentile(newPercentile: PercentileId) { 30 | percentileId.value = newPercentile; 31 | } 32 | loadSettingsFromStorage(); 33 | watch([percentileId], saveSettingsToStorage); 34 | 35 | function loadFromStorage() { 36 | try { 37 | const stored = localStorage.getItem(STORAGE_KEY); 38 | if (stored) intervalStats.value = JSON.parse(stored); 39 | } catch (err) { 40 | error.value = 'Failed to load interval stats.'; 41 | } 42 | } 43 | function saveToStorage() { 44 | try { 45 | localStorage.setItem(STORAGE_KEY, JSON.stringify(intervalStats.value)); 46 | } catch (err) { 47 | error.value = 'Failed to save interval stats.'; 48 | } 49 | } 50 | watch(intervalStats, saveToStorage, { deep: true }); 51 | 52 | async function fetchIntervalStatsIfNeeded(lang: string, deckId: string, percentileParam: PercentileId = percentileId.value) { 53 | if (!lang) return; 54 | isLoading.value = true; 55 | try { 56 | const stats = await fetchIntervalStats(lang, deckId, percentileParam); 57 | intervalStats.value = stats; 58 | error.value = ''; 59 | } catch (e) { 60 | error.value = 'Interval stats fetch failed'; 61 | } finally { 62 | isLoading.value = false; 63 | } 64 | } 65 | 66 | async function refetch(lang: string, deckId: string) { 67 | isLoading.value = true; 68 | error.value = ''; 69 | intervalStats.value = null; 70 | await reloadDatabase(); 71 | return fetchIntervalStatsIfNeeded(lang, deckId, percentileId.value); 72 | } 73 | 74 | return { 75 | intervalStats, 76 | isLoading, 77 | error, 78 | percentileId, 79 | setPercentile, 80 | fetchIntervalStatsIfNeeded, 81 | refetch, 82 | loadFromStorage, 83 | loadSettingsFromStorage, 84 | }; 85 | }); 86 | -------------------------------------------------------------------------------- /src/stores/dueStats.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia'; 2 | import { ref, watch } from 'vue'; 3 | import { fetchDueStats, reloadDatabase } from '../utils/database'; 4 | import type { DueStats } from '../types/Database'; 5 | import type { PeriodId } from './reviewHistory'; 6 | const STORAGE_KEY = 'migaku-dueStats'; 7 | const SETTINGS_KEY = 'migaku-dueStats-settings'; 8 | 9 | export const useDueStatsStore = defineStore('dueStats', () => { 10 | const dueStats = ref(null); 11 | const isLoading = ref(false); 12 | const error = ref(''); 13 | const periodId = ref('1 Month'); 14 | 15 | function loadSettingsFromStorage() { 16 | try { 17 | const data = localStorage.getItem(SETTINGS_KEY); 18 | if (data) { 19 | const parsed = JSON.parse(data); 20 | if (parsed.periodId && ["1 Month", "2 Months", "3 Months", "6 Months", "1 Year", "All time"].includes(parsed.periodId)) periodId.value = parsed.periodId; 21 | } 22 | } catch {} 23 | } 24 | function saveSettingsToStorage() { 25 | localStorage.setItem(SETTINGS_KEY, JSON.stringify({ periodId: periodId.value })); 26 | } 27 | function setPeriod(newPeriodId: PeriodId) { 28 | periodId.value = newPeriodId; 29 | } 30 | loadSettingsFromStorage(); 31 | watch([periodId], saveSettingsToStorage); 32 | 33 | function loadFromStorage() { 34 | try { 35 | const stored = localStorage.getItem(STORAGE_KEY); 36 | if (stored) dueStats.value = JSON.parse(stored); 37 | } catch (err) { 38 | error.value = 'Failed to load due stats.'; 39 | } 40 | } 41 | function saveToStorage() { 42 | try { 43 | localStorage.setItem(STORAGE_KEY, JSON.stringify(dueStats.value)); 44 | } catch (err) { 45 | error.value = 'Failed to save due stats.'; 46 | } 47 | } 48 | watch(dueStats, saveToStorage, { deep: true }); 49 | 50 | async function fetchDueStatsIfNeeded(lang: string, deckId: string, periodIdParam: PeriodId = periodId.value) { 51 | if (!lang) return; 52 | isLoading.value = true; 53 | try { 54 | const stats = await fetchDueStats(lang, deckId, periodIdParam); 55 | dueStats.value = stats; 56 | error.value = ''; 57 | } catch (e) { 58 | error.value = 'Due stats fetch failed'; 59 | } finally { 60 | isLoading.value = false; 61 | } 62 | } 63 | 64 | async function refetch(lang: string, deckId: string) { 65 | isLoading.value = true; 66 | error.value = ''; 67 | dueStats.value = null; 68 | await reloadDatabase(); 69 | return fetchDueStatsIfNeeded(lang, deckId, periodId.value); 70 | } 71 | 72 | function setDueStats(stats: DueStats|null) { dueStats.value = stats; } 73 | function clearDueStats() { dueStats.value = null; } 74 | 75 | return { 76 | dueStats, 77 | isLoading, 78 | error, 79 | periodId, 80 | setDueStats, 81 | clearDueStats, 82 | fetchDueStatsIfNeeded, 83 | refetch, 84 | loadFromStorage, 85 | setPeriod, 86 | loadSettingsFromStorage 87 | }; 88 | }); 89 | -------------------------------------------------------------------------------- /src/utils/observers.ts: -------------------------------------------------------------------------------- 1 | import { ATTRIBUTES, APP_SETTINGS } from './constants'; 2 | import { logger } from './logger'; 3 | 4 | export function waitForElement(selector: string, timeout: number = APP_SETTINGS.DEFAULT_TIMEOUT): Promise { 5 | return new Promise((resolve) => { 6 | const observer = new MutationObserver((_, obs) => { 7 | const element = document.querySelector(selector); 8 | if (element) { 9 | logger.debug(`Element '${selector}' detected.`); 10 | obs.disconnect(); 11 | resolve(element); 12 | } 13 | }); 14 | 15 | const element = document.querySelector(selector); 16 | if (element) { 17 | logger.debug(`Element '${selector}' found immediately.`); 18 | resolve(element); 19 | return; 20 | } 21 | 22 | observer.observe(document.body, { childList: true, subtree: true }); 23 | 24 | setTimeout(() => { 25 | if (!document.querySelector(selector)) { 26 | observer.disconnect(); 27 | logger.debug(`Element '${selector}' not found via MutationObserver after ${timeout}ms.`); 28 | resolve(null); 29 | } 30 | }, timeout); 31 | }); 32 | } 33 | 34 | export function setupThemeObserver(onThemeChange: (newTheme: string | null) => void): MutationObserver { 35 | logger.debug('Setting up theme change observer'); 36 | 37 | const themeObserver = new MutationObserver((mutationsList) => { 38 | for (const mutation of mutationsList) { 39 | if ( 40 | mutation.type === 'attributes' && 41 | mutation.attributeName === ATTRIBUTES.THEME 42 | ) { 43 | const newTheme = document.documentElement.getAttribute(ATTRIBUTES.THEME); 44 | logger.debug(`Theme changed to: ${newTheme}`); 45 | onThemeChange(newTheme); 46 | break; 47 | } 48 | } 49 | }); 50 | 51 | themeObserver.observe(document.documentElement, { 52 | attributes: true, 53 | attributeFilter: [ATTRIBUTES.THEME], 54 | }); 55 | 56 | logger.debug('Theme change observer attached.'); 57 | return themeObserver; 58 | } 59 | 60 | export function setupLanguageObserver( 61 | mainElement: Element, 62 | onLanguageChange: (newLanguage: string | null) => void 63 | ): MutationObserver { 64 | logger.debug('Setting up language change observer.'); 65 | 66 | const languageObserver = new MutationObserver((mutationsList) => { 67 | for (const mutation of mutationsList) { 68 | if ( 69 | mutation.type === 'attributes' && 70 | mutation.attributeName === ATTRIBUTES.LANG_SELECTED 71 | ) { 72 | const newLanguage = mainElement.getAttribute(ATTRIBUTES.LANG_SELECTED); 73 | logger.debug(`Language attribute changed to: ${newLanguage}`); 74 | onLanguageChange(newLanguage); 75 | break; 76 | } 77 | } 78 | }); 79 | 80 | languageObserver.observe(mainElement, { 81 | attributes: true, 82 | attributeFilter: [ATTRIBUTES.LANG_SELECTED], 83 | }); 84 | 85 | logger.debug('Language change observer attached.'); 86 | return languageObserver; 87 | } 88 | -------------------------------------------------------------------------------- /src/stores/wordStats.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia'; 2 | import { ref, watch } from 'vue'; 3 | import { fetchWordStats as dbFetchWordStats, reloadDatabase } from '../utils/database'; 4 | import type { WordStats } from '../types/Database'; 5 | const STORAGE_KEY = 'migaku-wordstats'; 6 | const SETTINGS_KEY = 'migaku-wordstats-settings'; 7 | 8 | export const useWordStatsStore = defineStore('wordStats', () => { 9 | const wordStats = ref(null); 10 | const isLoading = ref(false); 11 | const error = ref(''); 12 | const showUnknown = ref(true); 13 | const showIgnored = ref(true); 14 | 15 | function loadSettingsFromStorage() { 16 | try { 17 | const data = localStorage.getItem(SETTINGS_KEY); 18 | if (data) { 19 | const parsed = JSON.parse(data); 20 | if (typeof parsed.showUnknown === 'boolean') showUnknown.value = parsed.showUnknown; 21 | if (typeof parsed.showIgnored === 'boolean') showIgnored.value = parsed.showIgnored; 22 | } 23 | } catch {} 24 | } 25 | function saveSettingsToStorage() { 26 | localStorage.setItem(SETTINGS_KEY, JSON.stringify({ showUnknown: showUnknown.value, showIgnored: showIgnored.value })); 27 | } 28 | function setShowUnknown(val: boolean) { showUnknown.value = !!val; } 29 | function setShowIgnored(val: boolean) { showIgnored.value = !!val; } 30 | loadSettingsFromStorage(); 31 | watch([showUnknown, showIgnored], saveSettingsToStorage); 32 | 33 | function loadFromStorage() { 34 | try { 35 | const stored = localStorage.getItem(STORAGE_KEY); 36 | if (stored) wordStats.value = JSON.parse(stored); 37 | } catch (err) { 38 | error.value = 'Failed to load word stats.'; 39 | } 40 | } 41 | function saveToStorage() { 42 | try { 43 | localStorage.setItem(STORAGE_KEY, JSON.stringify(wordStats.value)); 44 | } catch (err) { 45 | error.value = 'Failed to save word stats.'; 46 | } 47 | } 48 | watch(wordStats, saveToStorage, { deep: true }); 49 | 50 | async function fetchWordStatsIfNeeded(lang: string, deckId: string) { 51 | if (!lang) return; 52 | isLoading.value = true; 53 | try { 54 | const stats = await dbFetchWordStats(lang, deckId); 55 | if (!stats) throw new Error('No word stats found'); 56 | wordStats.value = stats; 57 | error.value = ''; 58 | } catch (e) { 59 | error.value = 'Fetch failed'; 60 | } finally { 61 | isLoading.value = false; 62 | } 63 | } 64 | 65 | async function refetch(lang: string, deckId: string) { 66 | isLoading.value = true; 67 | error.value = ''; 68 | wordStats.value = null; 69 | await reloadDatabase(); 70 | return fetchWordStatsIfNeeded(lang, deckId); 71 | } 72 | 73 | function setWordStats(stats: any) { 74 | wordStats.value = stats; 75 | } 76 | function clearWordStats() { 77 | wordStats.value = null; 78 | } 79 | 80 | return { 81 | wordStats, 82 | isLoading, 83 | error, 84 | showUnknown, 85 | showIgnored, 86 | setShowUnknown, 87 | setShowIgnored, 88 | setWordStats, 89 | clearWordStats, 90 | fetchWordStatsIfNeeded, 91 | refetch, 92 | loadFromStorage 93 | }; 94 | }); 95 | -------------------------------------------------------------------------------- /src/stores/characterStats.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia'; 2 | import { ref, watch } from 'vue'; 3 | import { fetchCharacterStats as dbFetchCharacterStats, reloadDatabase } from '../utils/database'; 4 | import type { CharacterStats } from '../types/Database'; 5 | const STORAGE_KEY = 'migaku-characterstats'; 6 | const SETTINGS_KEY = 'migaku-characterstats-settings'; 7 | 8 | export const useCharacterStatsStore = defineStore('characterStats', () => { 9 | const characterStats = ref(null); 10 | const isLoading = ref(false); 11 | const error = ref(''); 12 | const gridCellWidth = ref(40); 13 | const selectedGrouping = ref(0); 14 | 15 | function loadSettingsFromStorage() { 16 | try { 17 | const data = localStorage.getItem(SETTINGS_KEY); 18 | if (data) { 19 | const parsed = JSON.parse(data); 20 | if (typeof parsed.gridCellWidth === 'number') gridCellWidth.value = parsed.gridCellWidth; 21 | if (typeof parsed.selectedGrouping === 'number') selectedGrouping.value = parsed.selectedGrouping; 22 | } 23 | } catch {} 24 | } 25 | function saveSettingsToStorage() { 26 | localStorage.setItem(SETTINGS_KEY, JSON.stringify({ 27 | gridCellWidth: gridCellWidth.value, 28 | selectedGrouping: selectedGrouping.value 29 | })); 30 | } 31 | function setGridCellWidth(val: number) { gridCellWidth.value = val; } 32 | function setSelectedGrouping(val: number) { selectedGrouping.value = val; } 33 | loadSettingsFromStorage(); 34 | watch([gridCellWidth, selectedGrouping], saveSettingsToStorage); 35 | 36 | function loadFromStorage() { 37 | try { 38 | const stored = localStorage.getItem(STORAGE_KEY); 39 | if (stored) characterStats.value = JSON.parse(stored); 40 | } catch (err) { 41 | error.value = 'Failed to load character stats.'; 42 | } 43 | } 44 | function saveToStorage() { 45 | try { 46 | localStorage.setItem(STORAGE_KEY, JSON.stringify(characterStats.value)); 47 | } catch (err) { 48 | error.value = 'Failed to save character stats.'; 49 | } 50 | } 51 | watch(characterStats, saveToStorage, { deep: true }); 52 | 53 | async function fetchCharacterStatsIfNeeded(lang: string) { 54 | if (!lang) return; 55 | isLoading.value = true; 56 | try { 57 | const stats = await dbFetchCharacterStats(lang); 58 | if (!stats) throw new Error('No character stats found'); 59 | characterStats.value = stats; 60 | error.value = ''; 61 | } catch (e) { 62 | error.value = 'Fetch failed'; 63 | } finally { 64 | isLoading.value = false; 65 | } 66 | } 67 | 68 | async function refetch(lang: string) { 69 | isLoading.value = true; 70 | error.value = ''; 71 | characterStats.value = null; 72 | await reloadDatabase(); 73 | return fetchCharacterStatsIfNeeded(lang); 74 | } 75 | 76 | function setCharacterStats(stats: any) { 77 | characterStats.value = stats; 78 | } 79 | function clearCharacterStats() { 80 | characterStats.value = null; 81 | } 82 | 83 | return { 84 | characterStats, 85 | isLoading, 86 | error, 87 | gridCellWidth, 88 | selectedGrouping, 89 | setGridCellWidth, 90 | setSelectedGrouping, 91 | setCharacterStats, 92 | clearCharacterStats, 93 | fetchCharacterStatsIfNeeded, 94 | refetch, 95 | loadFromStorage, 96 | loadSettingsFromStorage 97 | }; 98 | }); 99 | -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | type LogLevel = 'info' | 'warn' | 'error' | 'debug'; 2 | 3 | interface LogConfig { 4 | level: LogLevel; 5 | component?: string; 6 | bgColor: string; 7 | textColor: string; 8 | } 9 | 10 | const configs: Record> = { 11 | info: { 12 | level: 'info', 13 | bgColor: '#3498db', 14 | textColor: '#ffffff', 15 | }, 16 | warn: { 17 | level: 'warn', 18 | bgColor: '#f39c12', 19 | textColor: '#ffffff', 20 | }, 21 | error: { 22 | level: 'error', 23 | bgColor: '#e74c3c', 24 | textColor: '#ffffff', 25 | }, 26 | debug: { 27 | level: 'debug', 28 | bgColor: '#613075', 29 | textColor: '#ffffff', 30 | }, 31 | }; 32 | 33 | function getCallerComponent(): string { 34 | const stack = new Error().stack; 35 | if (!stack) return 'Unknown'; 36 | 37 | const stackLines = stack.split('\n'); 38 | 39 | for (let i = 0; i < stackLines.length; i++) { 40 | const line = stackLines[i]; 41 | 42 | if (line.includes('logger.ts')) continue; 43 | 44 | const vueMatches = [ 45 | line.match(/\(([^)]*\.vue[^:)]*)(?::\d+:\d+)?\)/), // (App.vue?hash:5:15) 46 | line.match(/@([^)]*\.vue[^:)]*)(?::\d+:\d+)?/), // @App.vue?hash:5:15 47 | line.match(/(\w+\.vue)/), // App.vue 48 | line.match(/(\w+\.vue[?:])/), // App.vue? or App.vue: 49 | ]; 50 | 51 | for (const match of vueMatches) { 52 | if (match) { 53 | const filePath = match[1]; 54 | const fileName = filePath.split('/').pop()?.split('\\').pop() || filePath; 55 | const cleanFileName = fileName.split('?')[0].split(':')[0]; 56 | const componentName = cleanFileName.replace('.vue', ''); 57 | if (componentName) return componentName; 58 | } 59 | } 60 | 61 | if (line.includes('Object.') || line.includes('Array.') || line.includes('') || line.includes('eval')) continue; 62 | 63 | const functionMatch = line.match(/at\s+(?:async\s+)?([A-Z][a-zA-Z0-9_$]+)/); 64 | if (functionMatch) { 65 | const funcName = functionMatch[1]; 66 | const skipList = ['Object', 'Array', 'Promise', 'String', 'Number', 'Boolean', 'Date', 'RegExp', 'Error', 'Function']; 67 | if (!skipList.includes(funcName)) { 68 | return funcName; 69 | } 70 | } 71 | } 72 | 73 | return 'Unknown'; 74 | } 75 | 76 | function createLogger(componentName?: string) { 77 | const log = (level: LogLevel, ...args: any[]) => { 78 | const caller = componentName || getCallerComponent(); 79 | const config = configs[level]; 80 | 81 | const header = `%cMCS ${caller}`; 82 | const message = args.length === 1 ? args[0] : args; 83 | 84 | console.log( 85 | header, 86 | `background: ${config.bgColor}; color: ${config.textColor}; padding: 2px 6px; border-radius: 3px; font-weight: bold;`, 87 | message 88 | ); 89 | }; 90 | 91 | return { 92 | log, 93 | info: (...args: any[]) => log('info', ...args), 94 | warn: (...args: any[]) => log('warn', ...args), 95 | error: (...args: any[]) => log('error', ...args), 96 | debug: (...args: any[]) => log('debug', ...args), 97 | }; 98 | } 99 | 100 | export const logger = createLogger(); 101 | 102 | export const useLogger = (componentName: string) => createLogger(componentName); 103 | 104 | -------------------------------------------------------------------------------- /src/stores/reviewHistory.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia'; 2 | import { ref, watch } from 'vue'; 3 | import { fetchReviewHistory, reloadDatabase } from '../utils/database'; 4 | import type { ReviewHistoryResult } from '../types/Database'; 5 | const STORAGE_KEY = 'migaku-reviewHistory'; 6 | const SETTINGS_KEY = 'migaku-reviewHistory-settings'; 7 | 8 | export type PeriodId = "1 Month" | "2 Months" | "3 Months" | "6 Months" | "1 Year" | "All time"; 9 | export type Grouping = "Days" | "Weeks" | "Months"; 10 | 11 | export const useReviewHistoryStore = defineStore('reviewHistory', () => { 12 | const reviewHistory = ref(null); 13 | const isLoading = ref(false); 14 | const error = ref(''); 15 | 16 | const grouping = ref('Days'); 17 | const periodId = ref('1 Month'); 18 | 19 | function loadSettingsFromStorage() { 20 | try { 21 | const data = localStorage.getItem(SETTINGS_KEY); 22 | if (data) { 23 | const parsed = JSON.parse(data); 24 | if (parsed.grouping && ['Days','Weeks','Months'].includes(parsed.grouping)) grouping.value = parsed.grouping; 25 | if (parsed.periodId && ["1 Month", "2 Months", "3 Months", "6 Months", "1 Year", "All time"].includes(parsed.periodId)) periodId.value = parsed.periodId; 26 | } 27 | } catch {} 28 | } 29 | function saveSettingsToStorage() { 30 | localStorage.setItem(SETTINGS_KEY, JSON.stringify({ grouping: grouping.value, periodId: periodId.value })); 31 | } 32 | function setGroupingAndPeriod(newGrouping: Grouping, newPeriodId: PeriodId) { 33 | grouping.value = newGrouping; 34 | periodId.value = newPeriodId; 35 | } 36 | loadSettingsFromStorage(); 37 | watch([grouping, periodId], saveSettingsToStorage); 38 | 39 | function loadFromStorage() { 40 | try { 41 | const stored = localStorage.getItem(STORAGE_KEY); 42 | if (stored) reviewHistory.value = JSON.parse(stored); 43 | } catch (err) { 44 | error.value = 'Failed to load review history.'; 45 | } 46 | } 47 | function saveToStorage() { 48 | try { 49 | localStorage.setItem(STORAGE_KEY, JSON.stringify(reviewHistory.value)); 50 | } catch (err) { 51 | error.value = 'Failed to save review history.'; 52 | } 53 | } 54 | watch(reviewHistory, saveToStorage, { deep: true }); 55 | 56 | async function fetchReviewHistoryIfNeeded(lang: string, deckId: string, periodIdParam: PeriodId = periodId.value, groupingParam: Grouping = grouping.value) { 57 | if (!lang) return; 58 | isLoading.value = true; 59 | try { 60 | const stats = await fetchReviewHistory(lang, deckId, periodIdParam, groupingParam); 61 | reviewHistory.value = stats; 62 | error.value = ''; 63 | } catch (e) { 64 | error.value = 'Review history fetch failed'; 65 | } finally { 66 | isLoading.value = false; 67 | } 68 | } 69 | 70 | async function refetch(lang: string, deckId: string) { 71 | isLoading.value = true; 72 | error.value = ''; 73 | reviewHistory.value = null; 74 | await reloadDatabase(); 75 | await fetchReviewHistoryIfNeeded(lang, deckId, periodId.value, grouping.value); 76 | } 77 | 78 | function setReviewHistory(stats: ReviewHistoryResult|null) { reviewHistory.value = stats; } 79 | function clearReviewHistory() { reviewHistory.value = null; } 80 | 81 | return { 82 | reviewHistory, 83 | isLoading, 84 | error, 85 | grouping, 86 | periodId, 87 | setReviewHistory, 88 | clearReviewHistory, 89 | fetchReviewHistoryIfNeeded, 90 | refetch, 91 | loadFromStorage, 92 | setGroupingAndPeriod, 93 | loadSettingsFromStorage, 94 | }; 95 | }); 96 | -------------------------------------------------------------------------------- /src/components/NativeStats.vue: -------------------------------------------------------------------------------- 1 | 97 | 101 | 102 | 111 | -------------------------------------------------------------------------------- /src/stores/studyStats.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from "pinia"; 2 | import { ref, watch } from "vue"; 3 | import { fetchStudyStats, reloadDatabase } from "../utils/database"; 4 | import type { StudyStats } from "../types/Database"; 5 | import type { PeriodId } from "./reviewHistory"; 6 | 7 | const STORAGE_KEY = "migaku-studyStats"; 8 | const SETTINGS_KEY = "migaku-studyStats-settings"; 9 | 10 | export const useStudyStatsStore = defineStore("studyStats", () => { 11 | const studyStats = ref(null); 12 | const isLoading = ref(false); 13 | const error = ref(""); 14 | const periodId = ref("1 Month"); 15 | const visibility = ref({ 16 | percGroup: true, 17 | totalsGroup: true, 18 | avgsGroup: true, 19 | timeGroup: true, 20 | daysStudiedPercent: true, 21 | passRate: true, 22 | totalReviews: true, 23 | avgReviewsPerDay: true, 24 | totalCardsAdded: true, 25 | cardsAddedPerDay: true, 26 | totalNewCards: true, 27 | newCardsPerDay: true, 28 | totalCardsLearned: true, 29 | cardsLearnedPerDay: true, 30 | totalTimeNewCards: true, 31 | avgTimeNewCard: true, 32 | totalTimeReviews: true, 33 | avgTimeReview: true, 34 | }); 35 | 36 | function loadSettingsFromStorage() { 37 | try { 38 | const data = localStorage.getItem(SETTINGS_KEY); 39 | if (data) { 40 | const parsed = JSON.parse(data); 41 | if ( 42 | [ 43 | "1 Month", 44 | "2 Months", 45 | "3 Months", 46 | "6 Months", 47 | "1 Year", 48 | "All time", 49 | ].includes(parsed.periodId) 50 | ) 51 | periodId.value = parsed.periodId; 52 | if (parsed.visibility && typeof parsed.visibility === "object") 53 | visibility.value = { ...visibility.value, ...parsed.visibility }; 54 | } 55 | } catch {} 56 | } 57 | function saveSettingsToStorage() { 58 | localStorage.setItem( 59 | SETTINGS_KEY, 60 | JSON.stringify({ periodId: periodId.value, visibility: visibility.value }) 61 | ); 62 | } 63 | function setPeriod(newPeriodId: PeriodId) { 64 | periodId.value = newPeriodId; 65 | } 66 | loadSettingsFromStorage(); 67 | watch([periodId, visibility], saveSettingsToStorage, { deep: true }); 68 | function setVisibility(key: keyof typeof visibility.value, value: boolean) { 69 | visibility.value = { ...visibility.value, [key]: value } as any; 70 | } 71 | function setVisibilities(values: Partial) { 72 | visibility.value = { ...visibility.value, ...values } as any; 73 | } 74 | 75 | function loadFromStorage() { 76 | try { 77 | const stored = localStorage.getItem(STORAGE_KEY); 78 | if (stored) studyStats.value = JSON.parse(stored); 79 | } catch (err) { 80 | error.value = "Failed to load study stats."; 81 | } 82 | } 83 | function saveToStorage() { 84 | try { 85 | localStorage.setItem(STORAGE_KEY, JSON.stringify(studyStats.value)); 86 | } catch (err) { 87 | error.value = "Failed to save study stats."; 88 | } 89 | } 90 | watch(studyStats, saveToStorage, { deep: true }); 91 | 92 | async function fetchStudyStatsIfNeeded( 93 | lang: string, 94 | deckId: string, 95 | periodParam: PeriodId = periodId.value 96 | ) { 97 | if (!lang) return; 98 | isLoading.value = true; 99 | try { 100 | const stats = await fetchStudyStats(lang, deckId, periodParam); 101 | studyStats.value = stats; 102 | error.value = ""; 103 | } catch (e) { 104 | error.value = "Study stats fetch failed"; 105 | } finally { 106 | isLoading.value = false; 107 | } 108 | } 109 | 110 | async function refetch(lang: string, deckId: string) { 111 | isLoading.value = true; 112 | error.value = ""; 113 | studyStats.value = null; 114 | await reloadDatabase(); 115 | return fetchStudyStatsIfNeeded(lang, deckId, periodId.value); 116 | } 117 | 118 | return { 119 | studyStats, 120 | isLoading, 121 | error, 122 | periodId, 123 | visibility, 124 | setPeriod, 125 | setVisibility, 126 | setVisibilities, 127 | fetchStudyStatsIfNeeded, 128 | refetch, 129 | loadFromStorage, 130 | loadSettingsFromStorage, 131 | }; 132 | }); 133 | -------------------------------------------------------------------------------- /src/stores/timeStats.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from "pinia"; 2 | import { ref, watch } from "vue"; 3 | import { fetchTimeHistory, reloadDatabase } from "../utils/database"; 4 | import type { TimeHistoryResult } from "../types/Database"; 5 | import type { PeriodId, Grouping } from "./reviewHistory"; 6 | 7 | const STORAGE_KEY = "migaku-timeStats"; 8 | const SETTINGS_KEY = "migaku-timeStats-settings"; 9 | 10 | export const useTimeStatsStore = defineStore("timeStats", () => { 11 | const timeHistory = ref(null); 12 | const isLoading = ref(false); 13 | const error = ref(""); 14 | const viewMode = ref<"totals" | "averages">("totals"); 15 | const grouping = ref("Days"); 16 | const periodId = ref("1 Month"); 17 | 18 | function loadSettingsFromStorage() { 19 | try { 20 | const data = localStorage.getItem(SETTINGS_KEY); 21 | if (data) { 22 | const parsed = JSON.parse(data); 23 | if ( 24 | parsed.viewMode && 25 | ["totals", "averages"].includes(parsed.viewMode) 26 | ) { 27 | viewMode.value = parsed.viewMode; 28 | } 29 | if ( 30 | parsed.grouping && 31 | ["Days", "Weeks", "Months"].includes(parsed.grouping) 32 | ) { 33 | grouping.value = parsed.grouping; 34 | } 35 | if ( 36 | parsed.periodId && 37 | [ 38 | "1 Month", 39 | "2 Months", 40 | "3 Months", 41 | "6 Months", 42 | "1 Year", 43 | "All time", 44 | ].includes(parsed.periodId) 45 | ) { 46 | periodId.value = parsed.periodId; 47 | } 48 | } 49 | } catch {} 50 | } 51 | 52 | function saveSettingsToStorage() { 53 | localStorage.setItem( 54 | SETTINGS_KEY, 55 | JSON.stringify({ 56 | viewMode: viewMode.value, 57 | grouping: grouping.value, 58 | periodId: periodId.value, 59 | }) 60 | ); 61 | } 62 | 63 | function setViewMode(mode: "totals" | "averages") { 64 | viewMode.value = mode; 65 | } 66 | 67 | function setGroupingAndPeriod(newGrouping: Grouping, newPeriodId: PeriodId) { 68 | grouping.value = newGrouping; 69 | periodId.value = newPeriodId; 70 | } 71 | 72 | loadSettingsFromStorage(); 73 | watch([viewMode, grouping, periodId], saveSettingsToStorage); 74 | 75 | function loadFromStorage() { 76 | try { 77 | const stored = localStorage.getItem(STORAGE_KEY); 78 | if (stored) timeHistory.value = JSON.parse(stored); 79 | } catch (err) { 80 | error.value = "Failed to load time history."; 81 | } 82 | } 83 | 84 | function saveToStorage() { 85 | try { 86 | localStorage.setItem(STORAGE_KEY, JSON.stringify(timeHistory.value)); 87 | } catch (err) { 88 | error.value = "Failed to save time history."; 89 | } 90 | } 91 | 92 | watch(timeHistory, saveToStorage, { deep: true }); 93 | 94 | async function fetchTimeHistoryIfNeeded( 95 | lang: string, 96 | deckId: string, 97 | periodIdParam: PeriodId = periodId.value, 98 | groupingParam: Grouping = grouping.value, 99 | viewModeParam: "totals" | "averages" = viewMode.value 100 | ) { 101 | if (!lang) return; 102 | isLoading.value = true; 103 | try { 104 | const stats = await fetchTimeHistory( 105 | lang, 106 | deckId, 107 | periodIdParam, 108 | groupingParam, 109 | viewModeParam 110 | ); 111 | timeHistory.value = stats; 112 | error.value = ""; 113 | } catch (e) { 114 | error.value = "Time history fetch failed"; 115 | } finally { 116 | isLoading.value = false; 117 | } 118 | } 119 | 120 | async function refetch(lang: string, deckId: string) { 121 | isLoading.value = true; 122 | error.value = ""; 123 | timeHistory.value = null; 124 | await reloadDatabase(); 125 | await fetchTimeHistoryIfNeeded( 126 | lang, 127 | deckId, 128 | periodId.value, 129 | grouping.value, 130 | viewMode.value 131 | ); 132 | } 133 | 134 | return { 135 | timeHistory, 136 | isLoading, 137 | error, 138 | viewMode, 139 | grouping, 140 | periodId, 141 | setViewMode, 142 | setGroupingAndPeriod, 143 | fetchTimeHistoryIfNeeded, 144 | refetch, 145 | loadFromStorage, 146 | loadSettingsFromStorage, 147 | }; 148 | }); 149 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue'; 2 | import { createPinia } from 'pinia'; 3 | import App from './App.vue'; 4 | import { waitForElement } from './utils/observers'; 5 | import { SELECTORS, ROUTES } from './utils/constants'; 6 | import { logger } from './utils/logger'; 7 | import { useAppStore } from './stores/app'; 8 | // @ts-ignore 9 | import { GM_addStyle } from 'monkey'; 10 | 11 | const VUE_CONTAINER_ID = SELECTORS.VUE_CONTAINER_ID; 12 | 13 | let vueAppInstance: ReturnType | null = null; 14 | let vueContainer: HTMLElement | null = null; 15 | let isMounting = false; 16 | 17 | async function mountApp() { 18 | if (isMounting || vueAppInstance || (vueContainer && document.getElementById(VUE_CONTAINER_ID))) { 19 | logger.debug('Vue app already mounting or mounted.'); 20 | return; 21 | } 22 | isMounting = true; 23 | try { 24 | logger.debug('Waiting for main Migaku element...'); 25 | const mainElement = await waitForElement(SELECTORS.MIGAKU_MAIN); 26 | if (!mainElement) { 27 | logger.error('Main Migaku element not found. App will not mount.'); 28 | return; 29 | } 30 | logger.debug('Main element found. Creating Vue app.'); 31 | const app = createApp(App); 32 | const pinia = createPinia(); 33 | app.use(pinia); 34 | const appStore = useAppStore(); 35 | appStore.loadFromStorage(); 36 | const statisticsDiv = await waitForElement(SELECTORS.STATISTICS_ELEMENT); 37 | if (!statisticsDiv) { 38 | logger.error("Statistics element not found, cannot display stats."); 39 | return; 40 | } 41 | 42 | // Get the component hash 43 | const componentHash = statisticsDiv.attributes[0].nodeName; 44 | appStore.setComponentHash(componentHash); 45 | logger.debug(`Component hash set to: ${componentHash}`); 46 | 47 | 48 | const statsContainer = await waitForElement(SELECTORS.TARGET_ELEMENT); 49 | if (!statsContainer || !(statsContainer instanceof HTMLElement)) { 50 | logger.error("Target container not found, cannot display stats."); 51 | return; 52 | } 53 | 54 | // Make the page full width 55 | statsContainer.style.maxWidth = "100vw"; 56 | 57 | // Add styles to the page 58 | GM_addStyle(`.Statistic__card[${componentHash}] { 59 | width: 100% !important; 60 | height: 100% !important; 61 | max-width: 1080px !important; 62 | }`); 63 | 64 | // Move heatmap card container to the new div 65 | const children = statsContainer.children; 66 | const newDiv = document.createElement("div"); 67 | newDiv.style.height = "100%"; 68 | newDiv.id = "original-stats-card-container"; 69 | Array.from(children).forEach(child => { 70 | newDiv.appendChild(child); 71 | }); 72 | statsContainer.appendChild(newDiv); 73 | 74 | logger.debug("Mounting Vue app to container"); 75 | vueContainer = document.createElement('div'); 76 | vueContainer.id = VUE_CONTAINER_ID; 77 | statsContainer.appendChild(vueContainer); 78 | vueAppInstance = app; 79 | vueAppInstance.mount(vueContainer); 80 | } finally { 81 | isMounting = false; 82 | } 83 | } 84 | 85 | function unmountApp() { 86 | if (vueAppInstance && vueContainer) { 87 | logger.debug('Unmounting Vue app.'); 88 | vueAppInstance.unmount(); 89 | if (vueContainer.parentNode) { 90 | vueContainer.parentNode.removeChild(vueContainer); 91 | } 92 | vueAppInstance = null; 93 | vueContainer = null; 94 | } 95 | isMounting = false; 96 | } 97 | 98 | function handleRouteChange() { 99 | if (window.location.pathname === ROUTES.STATS_ROUTE) { 100 | mountApp(); 101 | } else { 102 | unmountApp(); 103 | } 104 | } 105 | 106 | function monkeyPatchHistoryMethods() { 107 | const originalPushState = history.pushState; 108 | const originalReplaceState = history.replaceState; 109 | history.pushState = function (...args) { 110 | const ret = originalPushState.apply(this, args); 111 | window.dispatchEvent(new Event('locationchange')); 112 | return ret; 113 | }; 114 | history.replaceState = function (...args) { 115 | const ret = originalReplaceState.apply(this, args); 116 | window.dispatchEvent(new Event('locationchange')); 117 | return ret; 118 | }; 119 | } 120 | 121 | function setupRouteListener() { 122 | monkeyPatchHistoryMethods(); 123 | window.addEventListener('popstate', handleRouteChange); 124 | window.addEventListener('locationchange', handleRouteChange); 125 | window.addEventListener('hashchange', handleRouteChange); 126 | } 127 | 128 | setupRouteListener(); 129 | handleRouteChange(); 130 | -------------------------------------------------------------------------------- /src/stores/wordHistory.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia'; 2 | import { ref, watch } from 'vue'; 3 | import { fetchWordHistory, reloadDatabase } from '../utils/database'; 4 | import type { WordHistoryResult } from '../types/Database'; 5 | import type { PeriodId, Grouping } from './reviewHistory'; 6 | 7 | const STORAGE_KEY = 'migaku-wordHistory'; 8 | const SETTINGS_KEY = 'migaku-wordHistory-settings'; 9 | 10 | export const useWordHistoryStore = defineStore('wordHistory', () => { 11 | const wordHistory = ref(null); 12 | const isLoading = ref(false); 13 | const error = ref(''); 14 | const viewMode = ref<'cumulative' | 'daily'>('daily'); 15 | const grouping = ref('Days'); 16 | const periodId = ref('1 Month'); 17 | 18 | const offsetSettings = ref>({}); 19 | 20 | function loadSettingsFromStorage() { 21 | try { 22 | const data = localStorage.getItem(SETTINGS_KEY); 23 | if (data) { 24 | const parsed = JSON.parse(data); 25 | if (parsed.viewMode && ['cumulative', 'daily'].includes(parsed.viewMode)) { 26 | viewMode.value = parsed.viewMode; 27 | } 28 | if (parsed.grouping && ['Days','Weeks','Months'].includes(parsed.grouping)) { 29 | grouping.value = parsed.grouping; 30 | } 31 | if (parsed.periodId && ["1 Month", "2 Months", "3 Months", "6 Months", "1 Year", "All time"].includes(parsed.periodId)) { 32 | periodId.value = parsed.periodId; 33 | } 34 | if (parsed.offsetSettings && typeof parsed.offsetSettings === 'object') { 35 | offsetSettings.value = parsed.offsetSettings; 36 | } 37 | } 38 | } catch {} 39 | } 40 | 41 | function saveSettingsToStorage() { 42 | localStorage.setItem(SETTINGS_KEY, JSON.stringify({ 43 | viewMode: viewMode.value, 44 | grouping: grouping.value, 45 | periodId: periodId.value, 46 | offsetSettings: offsetSettings.value 47 | })); 48 | } 49 | 50 | function getOffsetForLanguage(language: string): { enabled: boolean; offset: number } { 51 | return offsetSettings.value[language] || { enabled: false, offset: 0 }; 52 | } 53 | 54 | function setOffsetForLanguage(language: string, enabled: boolean, offset: number) { 55 | offsetSettings.value[language] = { enabled, offset }; 56 | saveSettingsToStorage(); 57 | } 58 | 59 | function setViewMode(mode: 'cumulative' | 'daily') { 60 | viewMode.value = mode; 61 | } 62 | 63 | function setGroupingAndPeriod(newGrouping: Grouping, newPeriodId: PeriodId) { 64 | grouping.value = newGrouping; 65 | periodId.value = newPeriodId; 66 | } 67 | 68 | loadSettingsFromStorage(); 69 | watch([viewMode, grouping, periodId, offsetSettings], saveSettingsToStorage, { deep: true }); 70 | 71 | function loadFromStorage() { 72 | try { 73 | const stored = localStorage.getItem(STORAGE_KEY); 74 | if (stored) wordHistory.value = JSON.parse(stored); 75 | } catch (err) { 76 | error.value = 'Failed to load word history.'; 77 | } 78 | } 79 | 80 | function saveToStorage() { 81 | try { 82 | localStorage.setItem(STORAGE_KEY, JSON.stringify(wordHistory.value)); 83 | } catch (err) { 84 | error.value = 'Failed to save word history.'; 85 | } 86 | } 87 | 88 | watch(wordHistory, saveToStorage, { deep: true }); 89 | 90 | async function fetchWordHistoryIfNeeded( 91 | lang: string, 92 | deckId: string, 93 | periodIdParam: PeriodId = periodId.value, 94 | groupingParam: Grouping = grouping.value, 95 | viewModeParam: 'cumulative' | 'daily' = viewMode.value 96 | ) { 97 | if (!lang) return; 98 | isLoading.value = true; 99 | try { 100 | const stats = await fetchWordHistory(lang, deckId, periodIdParam, groupingParam, viewModeParam); 101 | wordHistory.value = stats; 102 | error.value = ''; 103 | } catch (e) { 104 | error.value = 'Word history fetch failed'; 105 | } finally { 106 | isLoading.value = false; 107 | } 108 | } 109 | 110 | async function refetch(lang: string, deckId: string) { 111 | isLoading.value = true; 112 | error.value = ''; 113 | wordHistory.value = null; 114 | await reloadDatabase(); 115 | await fetchWordHistoryIfNeeded(lang, deckId, periodId.value, grouping.value, viewMode.value); 116 | } 117 | 118 | function setWordHistory(stats: WordHistoryResult|null) { 119 | wordHistory.value = stats; 120 | } 121 | 122 | function clearWordHistory() { 123 | wordHistory.value = null; 124 | } 125 | 126 | return { 127 | wordHistory, 128 | isLoading, 129 | error, 130 | viewMode, 131 | grouping, 132 | periodId, 133 | offsetSettings, 134 | setWordHistory, 135 | clearWordHistory, 136 | fetchWordHistoryIfNeeded, 137 | refetch, 138 | loadFromStorage, 139 | setGroupingAndPeriod, 140 | setViewMode, 141 | loadSettingsFromStorage, 142 | getOffsetForLanguage, 143 | setOffsetForLanguage, 144 | }; 145 | }); 146 | -------------------------------------------------------------------------------- /src/stores/cards.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia'; 2 | import { ref, computed } from 'vue'; 3 | import { Layout, GridItemProps } from 'grid-layout-plus'; 4 | 5 | export interface CardState { 6 | id: string; 7 | visible: boolean; 8 | item: GridItemProps; 9 | } 10 | const STORAGE_KEY = 'migaku-cards'; 11 | const DEFAULT_CARDS: CardState[] = [ 12 | { id: 'NativeStats', visible: true, item: { i: 'NativeStats', x: 0, y: 0, w: 6, h: 17, minW: 6, maxW: 12, minH: 5, maxH: Infinity } }, 13 | { id: 'WordCount', visible: true, item: { i: 'WordCount', x: 6, y: 0, w: 6, h: 5, minW: 4, maxW: 12, minH: 5, maxH: 8 } }, 14 | { id: 'CardsDue', visible: true, item: { i: 'CardsDue', x: 6, y: 5, w: 6, h: 6, minW: 4, maxW: 12, minH: 5, maxH: 8 } }, 15 | { id: 'ReviewHistory', visible: true, item: { i: 'ReviewHistory', x: 0, y: 17, w: 6, h: 6, minW: 4, maxW: 12, minH: 5, maxH: 8 } }, 16 | { id: 'ReviewIntervals', visible: true, item: { i: 'ReviewIntervals', x: 6, y: 11, w: 6, h: 6, minW: 4, maxW: 12, minH: 5, maxH: 8 } }, 17 | { id: 'StudyStatistics', visible: true, item: { i: 'StudyStatistics', x: 6, y: 17, w: 6, h: 16, minW: 4, maxW: 12, minH: 5, maxH: Infinity } }, 18 | { id: 'TimeChart', visible: true, item: { i: 'TimeChart', x: 0, y: 23, w: 6, h: 6, minW: 4, maxW: 12, minH: 5, maxH: 8 } }, 19 | { id: 'KnownWordHistory', visible: true, item: { i: 'KnownWordHistory', x: 0, y: 29, w: 6, h: 6, minW: 4, maxW: 12, minH: 5, maxH: 8 } }, 20 | { id: 'CharacterStats', visible: true, item: { i: 'CharacterStats', x: 0, y: 34, w: 12, h: 14, minW: 6, maxW: 12, minH: 5, maxH: Infinity } } 21 | ]; 22 | 23 | export const useCardsStore = defineStore('cards', () => { 24 | const cards = ref([...DEFAULT_CARDS]); 25 | const isMoveModeActive = ref(false); 26 | 27 | function loadFromStorage() { 28 | try { 29 | const stored = localStorage.getItem(STORAGE_KEY); 30 | if (stored) { 31 | const loaded = JSON.parse(stored); 32 | if (Array.isArray(loaded)) { 33 | const merged = loaded.map((userCard: any) => { 34 | const defaultCard = DEFAULT_CARDS.find(d => d.id === userCard.id); 35 | if (defaultCard) { 36 | return { 37 | ...defaultCard, 38 | ...userCard, 39 | item: { 40 | ...defaultCard.item, 41 | ...(userCard.item || {}) 42 | } 43 | }; 44 | } else { 45 | return userCard; 46 | } 47 | }); 48 | for (const defaultCard of DEFAULT_CARDS) { 49 | if (!merged.some((c: any) => c.id === defaultCard.id)) { 50 | merged.push(defaultCard); 51 | } 52 | } 53 | cards.value = merged; 54 | } 55 | } 56 | } catch (error) { 57 | console.error('Failed to load cards from localStorage:', error); 58 | } 59 | } 60 | 61 | loadFromStorage(); 62 | 63 | function saveToStorage() { 64 | try { 65 | const data = cards.value.map(card => ({ 66 | id: card.id, 67 | visible: card.visible, 68 | item: { 69 | i: card.item.i, 70 | x: card.item.x, 71 | y: card.item.y, 72 | w: card.item.w, 73 | h: card.item.h, 74 | } 75 | })); 76 | localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); 77 | } catch (error) { 78 | console.error('Failed to save cards to localStorage:', error); 79 | } 80 | } 81 | 82 | function hideCard(id: string) { 83 | const card = cards.value.find(c => c.id === id); 84 | if (card) card.visible = false; 85 | } 86 | function showCard(id: string) { 87 | const card = cards.value.find(c => c.id === id); 88 | if (card) card.visible = true; 89 | } 90 | function toggleCardVisibility(id: string) { 91 | const card = cards.value.find(c => c.id === id); 92 | if (card) card.visible = !card.visible; 93 | } 94 | 95 | function updateLayout(layoutArr: Layout): void { 96 | for (const layoutItem of layoutArr) { 97 | const card = cards.value.find(c => c.item.i === layoutItem.i); 98 | if (card) { 99 | card.item.x = layoutItem.x; 100 | card.item.y = layoutItem.y; 101 | card.item.w = layoutItem.w; 102 | card.item.h = layoutItem.h; 103 | } 104 | } 105 | saveToStorage(); 106 | } 107 | 108 | const layout = computed(() => 109 | cards.value.filter((card: CardState) => card.visible).map(card => card.item) 110 | ); 111 | 112 | function setMoveMode(value: boolean) { 113 | isMoveModeActive.value = value; 114 | } 115 | 116 | function ensureCard(id: string, opts?: Partial & { minW?: number; minH?: number, defaultW?: number, defaultH?: number }): void { 117 | let card = cards.value.find(c => c.id === id); 118 | if (!card) { 119 | const minW = opts?.minW ?? 4; 120 | const minH = opts?.minH ?? 5; 121 | const w = opts?.w ?? opts?.defaultW ?? minW; 122 | const h = opts?.h ?? opts?.defaultH ?? minH; 123 | const x = opts?.x ?? 0; 124 | const y = opts?.y ?? 1000; 125 | const item: GridItemProps = { 126 | i: id, 127 | x, 128 | y, 129 | w, 130 | h, 131 | minW, 132 | minH, 133 | } as any; 134 | card = { id, visible: true, item }; 135 | cards.value.push(card); 136 | } else { 137 | card.visible = true; 138 | if (typeof opts?.minW === 'number') card.item.minW = opts!.minW as any; 139 | if (typeof opts?.minH === 'number') card.item.minH = opts!.minH as any; 140 | } 141 | saveToStorage(); 142 | } 143 | 144 | return { 145 | cards, 146 | layout, 147 | isMoveModeActive, 148 | hideCard, 149 | showCard, 150 | toggleCardVisibility, 151 | loadFromStorage, 152 | updateLayout, 153 | setMoveMode, 154 | ensureCard 155 | }; 156 | }); 157 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | ## GITATTRIBUTES FOR WEB PROJECTS 2 | # 3 | # These settings are for any web project. 4 | # 5 | # Details per file setting: 6 | # text These files should be normalized (i.e. convert CRLF to LF). 7 | # binary These files are binary and should be left untouched. 8 | # 9 | # Note that binary is a macro for -text -diff. 10 | ###################################################################### 11 | 12 | # Auto detect 13 | ## Handle line endings automatically for files detected as 14 | ## text and leave all files detected as binary untouched. 15 | ## This will handle all files NOT defined below. 16 | * text=auto 17 | 18 | # Source code 19 | *.bash text eol=lf 20 | *.bat text eol=crlf 21 | *.cmd text eol=crlf 22 | *.coffee text 23 | *.css text diff=css 24 | *.htm text diff=html 25 | *.html text diff=html 26 | *.inc text 27 | *.ini text 28 | *.js text 29 | *.mjs text 30 | *.cjs text 31 | *.json text 32 | *.jsx text 33 | *.less text 34 | *.ls text 35 | *.map text -diff 36 | *.od text 37 | *.onlydata text 38 | *.php text diff=php 39 | *.pl text 40 | *.ps1 text eol=crlf 41 | *.py text diff=python 42 | *.rb text diff=ruby 43 | *.sass text 44 | *.scm text 45 | *.scss text diff=css 46 | *.sh text eol=lf 47 | .husky/* text eol=lf 48 | *.sql text 49 | *.styl text 50 | *.tag text 51 | *.ts text 52 | *.tsx text 53 | *.xml text 54 | *.xhtml text diff=html 55 | 56 | # Docker 57 | Dockerfile text 58 | 59 | # Documentation 60 | *.ipynb text eol=lf 61 | *.markdown text diff=markdown 62 | *.md text diff=markdown 63 | *.mdwn text diff=markdown 64 | *.mdown text diff=markdown 65 | *.mkd text diff=markdown 66 | *.mkdn text diff=markdown 67 | *.mdtxt text 68 | *.mdtext text 69 | *.txt text 70 | AUTHORS text 71 | CHANGELOG text 72 | CHANGES text 73 | CONTRIBUTING text 74 | COPYING text 75 | copyright text 76 | *COPYRIGHT* text 77 | INSTALL text 78 | license text 79 | LICENSE text 80 | NEWS text 81 | readme text 82 | *README* text 83 | TODO text 84 | 85 | # Templates 86 | *.dot text 87 | *.ejs text 88 | *.erb text 89 | *.haml text 90 | *.handlebars text 91 | *.hbs text 92 | *.hbt text 93 | *.jade text 94 | *.latte text 95 | *.mustache text 96 | *.njk text 97 | *.phtml text 98 | *.svelte text 99 | *.tmpl text 100 | *.tpl text 101 | *.twig text 102 | *.vue text 103 | 104 | # Configs 105 | *.cnf text 106 | *.conf text 107 | *.config text 108 | .editorconfig text 109 | *.env text 110 | .gitattributes text 111 | .gitconfig text 112 | .htaccess text 113 | *.lock text -diff 114 | package.json text eol=lf 115 | package-lock.json text eol=lf -diff 116 | pnpm-lock.yaml text eol=lf -diff 117 | .prettierrc text 118 | yarn.lock text -diff 119 | *.toml text 120 | *.yaml text 121 | *.yml text 122 | browserslist text 123 | Makefile text 124 | makefile text 125 | # Fixes syntax highlighting on GitHub to allow comments 126 | tsconfig.json linguist-language=JSON-with-Comments 127 | 128 | # Heroku 129 | Procfile text 130 | 131 | # Graphics 132 | *.ai binary 133 | *.bmp binary 134 | *.eps binary 135 | *.gif binary 136 | *.gifv binary 137 | *.ico binary 138 | *.jng binary 139 | *.jp2 binary 140 | *.jpg binary 141 | *.jpeg binary 142 | *.jpx binary 143 | *.jxr binary 144 | *.pdf binary 145 | *.png binary 146 | *.psb binary 147 | *.psd binary 148 | # SVG treated as an asset (binary) by default. 149 | *.svg text 150 | # If you want to treat it as binary, 151 | # use the following line instead. 152 | # *.svg binary 153 | *.svgz binary 154 | *.tif binary 155 | *.tiff binary 156 | *.wbmp binary 157 | *.webp binary 158 | 159 | # Audio 160 | *.kar binary 161 | *.m4a binary 162 | *.mid binary 163 | *.midi binary 164 | *.mp3 binary 165 | *.ogg binary 166 | *.ra binary 167 | 168 | # Video 169 | *.3gpp binary 170 | *.3gp binary 171 | *.as binary 172 | *.asf binary 173 | *.asx binary 174 | *.avi binary 175 | *.fla binary 176 | *.flv binary 177 | *.m4v binary 178 | *.mng binary 179 | *.mov binary 180 | *.mp4 binary 181 | *.mpeg binary 182 | *.mpg binary 183 | *.ogv binary 184 | *.swc binary 185 | *.swf binary 186 | *.webm binary 187 | 188 | # Archives 189 | *.7z binary 190 | *.gz binary 191 | *.jar binary 192 | *.rar binary 193 | *.tar binary 194 | *.zip binary 195 | 196 | # Fonts 197 | *.ttf binary 198 | *.eot binary 199 | *.otf binary 200 | *.woff binary 201 | *.woff2 binary 202 | 203 | # Executables 204 | *.exe binary 205 | *.pyc binary 206 | # Prevents massive diffs caused by vendored, minified files 207 | **/.yarn/releases/** binary 208 | **/.yarn/plugins/** binary 209 | 210 | # RC files (like .babelrc or .eslintrc) 211 | *.*rc text 212 | 213 | # Ignore files (like .npmignore or .gitignore) 214 | *.*ignore text 215 | 216 | # Prevents massive diffs from built files 217 | dist/* binary -------------------------------------------------------------------------------- /src/components/DropdownMenu.vue: -------------------------------------------------------------------------------- 1 | 95 | 96 | 171 | -------------------------------------------------------------------------------- /src/components/CharacterGrid.vue: -------------------------------------------------------------------------------- 1 | 111 | 112 | 146 | 147 | -------------------------------------------------------------------------------- /src/utils/kanjiDatabase.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import { GM_xmlhttpRequest } from 'monkey'; 3 | import initSqlJs, { Database, SqlJsStatic } from 'sql.js'; 4 | import { logger } from './logger'; 5 | import { KANJI_BY_JLPT_QUERY, KANJI_BY_KANKEN_QUERY, KANJI_BY_JOYO_QUERY } from './sql-queries'; 6 | import type { KanjiMetadata } from '../types/Database'; 7 | 8 | const KANJI_DB_URL = 'https://github.com/migaku-official/Migaku-Kanji-Addon/blob/main/addon/kanji.db?raw=true'; 9 | 10 | interface KanjiDatabaseState { 11 | sql: SqlJsStatic | null; 12 | db: Database | null; 13 | isLoading: boolean; 14 | error: string | null; 15 | } 16 | 17 | const kanjiDbState: KanjiDatabaseState = { 18 | sql: null, 19 | db: null, 20 | isLoading: false, 21 | error: null, 22 | }; 23 | 24 | const filterCache = new Map(); 25 | 26 | async function initializeSqlEngine(): Promise { 27 | try { 28 | if (kanjiDbState.sql) { 29 | logger.debug('Using existing SQL.js instance for kanji DB'); 30 | return kanjiDbState.sql; 31 | } 32 | 33 | logger.debug('Initializing SQL.js for kanji DB...'); 34 | 35 | const SQL = await initSqlJs({ 36 | locateFile: (file: string) => { 37 | logger.debug(`Locating file: ${file}`); 38 | if (file.endsWith('.wasm')) { 39 | return 'https://cdn.jsdelivr.net/npm/sql.js@1.13.0/dist/sql-wasm.wasm'; 40 | } 41 | return file; 42 | }, 43 | }); 44 | 45 | if (!SQL) { 46 | throw new Error('SQL.js initialization returned null'); 47 | } 48 | 49 | logger.debug('SQL.js initialized successfully for kanji DB'); 50 | kanjiDbState.sql = SQL; 51 | return SQL; 52 | } catch (err) { 53 | logger.error('Failed to initialize SQL.js for kanji DB:', err); 54 | return null; 55 | } 56 | } 57 | 58 | async function loadKanjiDatabase(): Promise { 59 | try { 60 | if (kanjiDbState.db) { 61 | logger.debug('Using existing kanji database instance'); 62 | return kanjiDbState.db; 63 | } 64 | 65 | if (kanjiDbState.isLoading) { 66 | logger.debug('Kanji database already loading, waiting...'); 67 | let attempts = 0; 68 | while (kanjiDbState.isLoading && attempts < 50) { 69 | await new Promise((resolve) => setTimeout(resolve, 100)); 70 | attempts++; 71 | } 72 | return kanjiDbState.db; 73 | } 74 | 75 | kanjiDbState.isLoading = true; 76 | kanjiDbState.error = null; 77 | 78 | logger.debug('Fetching kanji.db from GitHub...'); 79 | 80 | const arrayBuffer = await new Promise((resolve, reject) => { 81 | GM_xmlhttpRequest({ 82 | method: 'GET', 83 | url: KANJI_DB_URL, 84 | responseType: 'arraybuffer', 85 | onload: (response: any) => { 86 | if (!response || !response.response) { 87 | reject(new Error('Empty response when fetching kanji.db')); 88 | return; 89 | } 90 | logger.debug(`Downloaded kanji.db: ${response.response.byteLength} bytes`); 91 | resolve(response.response as ArrayBuffer); 92 | }, 93 | onerror: (error: any) => { 94 | reject(new Error(`Failed to fetch kanji.db: ${error}`)); 95 | }, 96 | }); 97 | }); 98 | 99 | const SQL = await initializeSqlEngine(); 100 | if (!SQL) { 101 | kanjiDbState.error = 'Failed to initialize SQL.js'; 102 | kanjiDbState.isLoading = false; 103 | return null; 104 | } 105 | 106 | logger.debug('Loading kanji database into SQL.js...'); 107 | const db = new SQL.Database(new Uint8Array(arrayBuffer)); 108 | logger.debug('Kanji database loaded successfully'); 109 | 110 | kanjiDbState.db = db; 111 | kanjiDbState.isLoading = false; 112 | return db; 113 | } catch (err) { 114 | logger.error('Failed to load kanji database:', err); 115 | kanjiDbState.error = err instanceof Error ? err.message : 'Unknown error'; 116 | kanjiDbState.isLoading = false; 117 | return null; 118 | } 119 | } 120 | 121 | export function clearKanjiDatabaseCache(): void { 122 | logger.debug('Clearing kanji database cache'); 123 | if (kanjiDbState.db) { 124 | try { 125 | kanjiDbState.db.close(); 126 | } catch (err) { 127 | logger.warn('Error closing kanji database:', err); 128 | } 129 | kanjiDbState.db = null; 130 | } 131 | filterCache.clear(); 132 | } 133 | 134 | export async function reloadKanjiDatabase(): Promise { 135 | logger.debug('Reloading kanji database'); 136 | clearKanjiDatabaseCache(); 137 | return loadKanjiDatabase(); 138 | } 139 | 140 | export async function fetchFilteredKanji( 141 | filterId: number, 142 | knownCharacters: string[], 143 | learningCharacters: string[] 144 | ): Promise { 145 | try { 146 | if (filterId === 0) { 147 | const allChars = [...new Set([...knownCharacters, ...learningCharacters])]; 148 | return allChars.map(char => ({ character: char, level: 0 })); 149 | } 150 | 151 | if (filterCache.has(filterId)) { 152 | logger.debug(`Using cached filter results for filterId=${filterId}`); 153 | return filterCache.get(filterId)!; 154 | } 155 | 156 | const db = await loadKanjiDatabase(); 157 | if (!db) { 158 | logger.error('Failed to load kanji database'); 159 | return []; 160 | } 161 | 162 | logger.debug(`Fetching filtered kanji for filterId: ${filterId}`); 163 | 164 | const filterOption = FILTER_OPTIONS.find(opt => opt.id === filterId); 165 | if (!filterOption || !filterOption.query) { 166 | logger.warn(`No query defined for filterId: ${filterId}`); 167 | return []; 168 | } 169 | 170 | const results = db.exec(filterOption.query); 171 | 172 | if (results.length === 0 || results[0].values.length === 0) { 173 | logger.warn(`No results for filterId: ${filterId}`); 174 | return []; 175 | } 176 | 177 | const filteredKanji: KanjiMetadata[] = results[0].values.map((row: any[]) => ({ 178 | character: String(row[0]), 179 | level: Number(row[1]) 180 | })); 181 | 182 | filterCache.set(filterId, filteredKanji); 183 | 184 | logger.debug(`Fetched ${filteredKanji.length} kanji for filterId: ${filterId}`); 185 | return filteredKanji; 186 | } catch (error) { 187 | logger.error('Error fetching filtered kanji:', error); 188 | return []; 189 | } 190 | } 191 | 192 | export interface FilterOption { 193 | id: number; 194 | label: string; 195 | levelLabel: (level: number) => string | null; 196 | query?: string; 197 | } 198 | 199 | export const FILTER_OPTIONS: FilterOption[] = [ 200 | { 201 | id: 0, 202 | label: 'All Characters', 203 | levelLabel: () => null, 204 | }, 205 | { 206 | id: 1, 207 | label: 'Jōyō', 208 | levelLabel: () => null, 209 | query: KANJI_BY_JOYO_QUERY, 210 | }, 211 | { 212 | id: 2, 213 | label: 'JLPT', 214 | levelLabel: (level: number) => `N${level}`, 215 | query: KANJI_BY_JLPT_QUERY, 216 | }, 217 | { 218 | id: 3, 219 | label: 'Kanken', 220 | levelLabel: (level: number) => `Level ${level}`, 221 | query: KANJI_BY_KANKEN_QUERY, 222 | }, 223 | ]; 224 | 225 | export function getKanjiDatabaseError(): string | null { 226 | return kanjiDbState.error; 227 | } 228 | 229 | export function isKanjiDatabaseLoading(): boolean { 230 | return kanjiDbState.isLoading; 231 | } 232 | -------------------------------------------------------------------------------- /src/utils/sql-queries.ts: -------------------------------------------------------------------------------- 1 | import { WORD_STATUS } from "./constants"; 2 | 3 | export const WORD_QUERY = ` 4 | SELECT 5 | SUM(CASE WHEN knownStatus = '${WORD_STATUS.KNOWN}' THEN 1 ELSE 0 END) as known_count, 6 | SUM(CASE WHEN knownStatus = '${WORD_STATUS.LEARNING}' THEN 1 ELSE 0 END) as learning_count, 7 | SUM(CASE WHEN knownStatus = '${WORD_STATUS.UNKNOWN}' THEN 1 ELSE 0 END) as unknown_count, 8 | SUM(CASE WHEN knownStatus = '${WORD_STATUS.IGNORED}' THEN 1 ELSE 0 END) as ignored_count 9 | FROM WordList 10 | WHERE language = ? AND del = 0`; 11 | 12 | export const WORD_QUERY_WITH_DECK = ` 13 | SELECT 14 | SUM(CASE WHEN w.knownStatus = '${WORD_STATUS.KNOWN}' THEN 1 ELSE 0 END) as known_count, 15 | SUM(CASE WHEN w.knownStatus = '${WORD_STATUS.LEARNING}' THEN 1 ELSE 0 END) as learning_count, 16 | SUM(CASE WHEN w.knownStatus = '${WORD_STATUS.UNKNOWN}' THEN 1 ELSE 0 END) as unknown_count, 17 | SUM(CASE WHEN w.knownStatus = '${WORD_STATUS.IGNORED}' THEN 1 ELSE 0 END) as ignored_count 18 | FROM ( 19 | SELECT DISTINCT w.dictForm, w.knownStatus 20 | FROM WordList w 21 | JOIN CardWordRelation cwr ON w.dictForm = cwr.dictForm 22 | JOIN card c ON cwr.cardId = c.id 23 | JOIN deck d ON c.deckId = d.id 24 | WHERE w.language = ? AND w.del = 0 AND d.id = ? AND c.del = 0 25 | ) as w`; 26 | 27 | export const WORDS_BY_STATUS_QUERY = ` 28 | SELECT dictForm 29 | FROM WordList 30 | WHERE language = ? AND knownStatus = ? AND del = 0`; 31 | 32 | 33 | export const KANJI_BY_JLPT_QUERY = ` 34 | SELECT character, jlpt AS level 35 | FROM characters 36 | WHERE jlpt IS NOT NULL 37 | ORDER BY jlpt DESC`; 38 | 39 | export const KANJI_BY_KANKEN_QUERY = ` 40 | SELECT character, kanken AS level 41 | FROM characters 42 | WHERE kanken IS NOT NULL 43 | ORDER BY kanken DESC`; 44 | 45 | export const KANJI_BY_JOYO_QUERY = ` 46 | SELECT character, frequency_rank AS level 47 | FROM characters 48 | WHERE grade <= 8 49 | ORDER BY frequency_rank ASC`; 50 | 51 | export const DECKS_QUERY = ` 52 | SELECT id, name, lang 53 | FROM deck 54 | WHERE del = 0 55 | ORDER BY name;`; 56 | 57 | export const DUE_QUERY = ` 58 | SELECT 59 | due, 60 | CASE 61 | WHEN c.interval < 20 THEN 'learning' 62 | ELSE 'known' 63 | END as interval_range, 64 | COUNT(*) as count 65 | FROM card c 66 | JOIN card_type ct ON c.cardTypeId = ct.id 67 | WHERE ct.lang = ? AND c.due BETWEEN ? AND ? AND c.del = 0`; 68 | 69 | export const INTERVAL_QUERY = ` 70 | SELECT 71 | ROUND(interval) as interval_group, 72 | COUNT(*) as count 73 | FROM card c 74 | JOIN card_type ct ON c.cardTypeId = ct.id 75 | WHERE ct.lang = ? AND c.del = 0 AND c.interval > 0 76 | GROUP BY interval_group 77 | ORDER BY interval_group`; 78 | 79 | export const REVIEW_HISTORY_QUERY = ` 80 | SELECT 81 | r.day, 82 | r.type, 83 | COUNT(DISTINCT r.cardId) as review_count 84 | FROM review r 85 | JOIN card c ON r.cardId = c.id 86 | JOIN card_type ct ON c.cardTypeId = ct.id 87 | JOIN reviewHistory rh ON r.day = rh.day 88 | WHERE ct.lang = ? AND r.day >= ? AND r.del = 0 89 | GROUP BY r.day, r.type 90 | ORDER BY r.day DESC, r.type`; 91 | 92 | export const STUDY_STATS_QUERY = ` 93 | SELECT 94 | COUNT(DISTINCT r.day) as days_studied, 95 | COUNT(*) as total_reviews 96 | FROM review r 97 | JOIN card c ON r.cardId = c.id 98 | JOIN card_type ct ON c.cardTypeId = ct.id 99 | WHERE ct.lang = ? AND r.day BETWEEN ? AND ? AND r.del = 0`; 100 | 101 | export const CURRENT_DATE_QUERY = ` 102 | SELECT entry 103 | FROM keyValue 104 | WHERE key = 'study.activeDay.currentDate';`; 105 | 106 | export const PASS_RATE_QUERY = ` 107 | SELECT 108 | SUM(CASE WHEN r.type = 2 THEN 1 ELSE 0 END) as successful_reviews, 109 | SUM(CASE WHEN r.type = 1 THEN 1 ELSE 0 END) as failed_reviews 110 | FROM review r 111 | JOIN card c ON r.cardId = c.id 112 | JOIN card_type ct ON c.cardTypeId = ct.id 113 | WHERE ct.lang = ? AND r.day BETWEEN ? AND ? AND r.del = 0 AND r.type IN (1, 2);`; 114 | 115 | export const NEW_CARDS_QUERY = ` 116 | SELECT 117 | COUNT(DISTINCT r.cardId) as new_cards_reviewed 118 | FROM review r 119 | JOIN card c ON r.cardId = c.id 120 | JOIN card_type ct ON c.cardTypeId = ct.id 121 | WHERE ct.lang = ? AND r.day BETWEEN ? AND ? AND r.del = 0 AND r.type = 0;`; 122 | 123 | export const CARDS_ADDED_QUERY = ` 124 | SELECT 125 | COUNT(*) as cards_added 126 | FROM card c 127 | JOIN card_type ct ON c.cardTypeId = ct.id 128 | WHERE ct.lang = ? AND c.created >= ? AND c.created <= ? AND c.del = 0 AND c.lessonId = '';`; 129 | 130 | export const CARDS_LEARNED_QUERY = ` 131 | SELECT 132 | COUNT(DISTINCT c.id) as cards_learned 133 | FROM review r 134 | JOIN card c ON r.cardId = c.id 135 | JOIN card_type ct ON c.cardTypeId = ct.id 136 | WHERE ct.lang = ? AND r.day BETWEEN ? AND ? AND r.del = 0 137 | AND c.interval >= 20 AND r.interval < 20 AND r.type = 2;`; 138 | 139 | export const TOTAL_NEW_CARDS_QUERY = ` 140 | SELECT 141 | COUNT(DISTINCT r.cardId) as total_new_cards 142 | FROM review r 143 | JOIN card c ON r.cardId = c.id 144 | JOIN card_type ct ON c.cardTypeId = ct.id 145 | WHERE ct.lang = ? AND r.day BETWEEN ? AND ? AND c.del = 0 AND r.del = 0 AND r.type = 0;`; 146 | 147 | export const CARDS_LEARNED_PER_DAY_QUERY = ` 148 | SELECT 149 | ROUND(COUNT(DISTINCT c.id) * 1.0 / NULLIF(COUNT(DISTINCT r.day), 0), 1) as cards_learned_per_day 150 | FROM review r 151 | JOIN card c ON r.cardId = c.id 152 | JOIN card_type ct ON c.cardTypeId = ct.id 153 | WHERE ct.lang = ? AND r.day BETWEEN ? AND ? AND r.del = 0 154 | AND c.interval >= 20 AND r.interval < 20 AND r.type = 2;`; 155 | 156 | export const NEW_CARDS_TIME_QUERY = ` 157 | SELECT 158 | SUM(r.duration) as total_time_seconds, 159 | COUNT(*) as review_count, 160 | ROUND(AVG(r.duration), 1) as avg_time_seconds 161 | FROM review r 162 | JOIN card c ON r.cardId = c.id 163 | JOIN card_type ct ON c.cardTypeId = ct.id 164 | WHERE ct.lang = ? AND r.day BETWEEN ? AND ? AND r.del = 0 AND r.type = 0;`; 165 | 166 | export const REVIEWS_TIME_QUERY = ` 167 | SELECT 168 | SUM(r.duration) as total_time_seconds, 169 | COUNT(*) as review_count, 170 | ROUND(AVG(r.duration), 1) as avg_time_seconds 171 | FROM review r 172 | JOIN card c ON r.cardId = c.id 173 | JOIN card_type ct ON c.cardTypeId = ct.id 174 | WHERE ct.lang = ? AND r.day BETWEEN ? AND ? AND r.del = 0 AND r.type IN (1, 2);`; 175 | 176 | export const TIME_HISTORY_QUERY = ` 177 | SELECT 178 | r.day, 179 | CASE 180 | WHEN r.type = 0 THEN 'new_cards' 181 | WHEN r.type IN (1, 2) THEN 'reviews' 182 | ELSE 'other' 183 | END as review_type, 184 | SUM(r.duration) as total_time_seconds, 185 | ROUND(AVG(r.duration), 1) as avg_time_seconds, 186 | COUNT(*) as review_count 187 | FROM review r 188 | JOIN card c ON r.cardId = c.id 189 | JOIN card_type ct ON c.cardTypeId = ct.id 190 | JOIN reviewHistory rh ON r.day = rh.day 191 | WHERE ct.lang = ? AND r.day >= ? AND r.del = 0 AND r.type IN (0, 1, 2) 192 | GROUP BY r.day, review_type 193 | ORDER BY r.day DESC, review_type`; 194 | 195 | export const WORD_HISTORY_QUERY = ` 196 | SELECT 197 | wh.day, 198 | wh.dictForm, 199 | wh.secondary, 200 | wh.partOfSpeech, 201 | wh.knownStatus, 202 | wh.prevKnownStatus 203 | FROM wordHistory wh 204 | WHERE wh.language = ? AND wh.day >= ? AND wh.del = 0 205 | ORDER BY wh.day ASC, wh.dictForm, wh.secondary, wh.partOfSpeech`; 206 | 207 | export const WORD_HISTORY_QUERY_WITH_DECK = ` 208 | SELECT DISTINCT 209 | wh.day, 210 | wh.dictForm, 211 | wh.secondary, 212 | wh.partOfSpeech, 213 | wh.knownStatus, 214 | wh.prevKnownStatus 215 | FROM wordHistory wh 216 | JOIN CardWordRelation cwr ON wh.dictForm = cwr.dictForm 217 | JOIN card c ON cwr.cardId = c.id 218 | JOIN deck d ON c.deckId = d.id 219 | WHERE wh.language = ? AND wh.day >= ? AND wh.del = 0 AND d.id = ? AND c.del = 0 220 | ORDER BY wh.day ASC, wh.dictForm, wh.secondary, wh.partOfSpeech`; 221 | -------------------------------------------------------------------------------- /src/components/FloatingMenuButton.vue: -------------------------------------------------------------------------------- 1 | 101 | 102 | 258 | 259 | 327 | -------------------------------------------------------------------------------- /src/components/ReviewHistory.vue: -------------------------------------------------------------------------------- 1 | 266 | 267 | 338 | 339 | 371 | -------------------------------------------------------------------------------- /src/components/ReviewIntervals.vue: -------------------------------------------------------------------------------- 1 | 269 | 270 | 339 | 340 | 370 | -------------------------------------------------------------------------------- /src/components/CardsDue.vue: -------------------------------------------------------------------------------- 1 | 307 | 308 | 374 | 375 | 407 | -------------------------------------------------------------------------------- /src/components/WordCount.vue: -------------------------------------------------------------------------------- 1 | 196 | 197 | 302 | 303 | 387 | -------------------------------------------------------------------------------- /src/components/TimeChart.vue: -------------------------------------------------------------------------------- 1 | 339 | 340 | 412 | 413 | 445 | -------------------------------------------------------------------------------- /src/components/CharacterStats.vue: -------------------------------------------------------------------------------- 1 | 222 | 223 | 352 | 353 | 437 | -------------------------------------------------------------------------------- /src/components/WordHistory.vue: -------------------------------------------------------------------------------- 1 | 374 | 375 | 471 | 472 | 504 | --------------------------------------------------------------------------------