fix/add tests, update heatmap range, finish android release, add readme
Stop tracking gradle.properties
99
README.md
@@ -0,0 +1,99 @@
|
|||||||
|
# ZenKanji
|
||||||
|
|
||||||
|
ZenKanji is a web and mobile application designed to help users learn and review Japanese Kanji effectively. It provides a modern and intuitive interface for studying, tracking progress, and staying motivated on your language-learning journey.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
This project is a monorepo containing two main packages:
|
||||||
|
|
||||||
|
- `client/`: A Vue.js 3 application built with Vite that serves as the frontend. It is also configured with Capacitor to allow for native mobile builds.
|
||||||
|
- `server/`: A Node.js and Express application that provides the backend API for user authentication, data synchronization, and study progression.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
To get the application running locally, you will need to set up both the `client` and the `server`.
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- [Node.js](https://nodejs.org/) (v18 or higher recommended)
|
||||||
|
- [npm](https://www.npmjs.com/)
|
||||||
|
- [Docker](https://www.docker.com/) (for running a local database)
|
||||||
|
|
||||||
|
### Server Setup
|
||||||
|
|
||||||
|
1. **Navigate to the server directory:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Install dependencies:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Set up environment variables:**
|
||||||
|
Create a `.env` file in the `server/` directory and configure your database connection and any other required variables.
|
||||||
|
|
||||||
|
4. **Start the development server:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The server should now be running on the port specified in your configuration (e.g., `http://localhost:3000`).
|
||||||
|
|
||||||
|
### Client Setup
|
||||||
|
|
||||||
|
1. **Navigate to the client directory:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Install dependencies:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Configure environment variables:**
|
||||||
|
Create a `.env` file in the `client/` directory to point to the correct backend API URL.
|
||||||
|
|
||||||
|
```
|
||||||
|
VITE_API_URL=http://localhost:3000/api/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Run the development server:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The application should be accessible at `http://localhost:5173`.
|
||||||
|
|
||||||
|
## Technologies Used
|
||||||
|
|
||||||
|
### Frontend (Client)
|
||||||
|
|
||||||
|
- **Framework**: [Vue.js 3](https://vuejs.org/)
|
||||||
|
- **Build Tool**: [Vite](https://vitejs.dev/)
|
||||||
|
- **Mobile**: [Capacitor](https://capacitorjs.com/)
|
||||||
|
- **Styling**: [Sass](https://sass-lang.com/)
|
||||||
|
- **State Management**: [Pinia](https://pinia.vuejs.org/)
|
||||||
|
- **Internationalization**: [Vue I18n](https://vue-i18n.intlify.dev/)
|
||||||
|
|
||||||
|
### Backend (Server)
|
||||||
|
|
||||||
|
- **Framework**: [Express.js](https://expressjs.com/)
|
||||||
|
- **Database**: [MongoDB](https://www.mongodb.com/)
|
||||||
|
- **Authentication**: JWT-based and using your Wanikani API Key for login
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Kanji Drawing**: Interactive canvas for practicing Kanji writing.
|
||||||
|
- **Spaced Repetition System (SRS)**: Smart review scheduling to optimize learning.
|
||||||
|
- **Progress Tracking**: Dashboard with widgets for accuracy, streaks, mastery, and more.
|
||||||
|
- **Cross-Platform Sync**: Synchronize your study progress across web and mobile devices.
|
||||||
|
- **Collections**: Browse and study Kanji by levels or themes.
|
||||||
|
|||||||
2
client/.gitignore
vendored
@@ -1,3 +1,5 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.env
|
.env
|
||||||
.env.android
|
.env.android
|
||||||
|
my-release-key.jks
|
||||||
|
gradle.properties
|
||||||
|
|||||||
@@ -11,17 +11,29 @@ android {
|
|||||||
versionName "1.0"
|
versionName "1.0"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
|
||||||
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
|
||||||
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
signingConfigs {
|
||||||
|
release {
|
||||||
|
storeFile file("my-release-key.jks")
|
||||||
|
storePassword RELEASE_KEY_PASSWORD
|
||||||
|
keyAlias "my-key-alias"
|
||||||
|
keyPassword RELEASE_KEY_PASSWORD
|
||||||
|
}
|
||||||
|
}
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
minifyEnabled false
|
signingConfig signingConfigs.release
|
||||||
|
minifyEnabled true
|
||||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
applicationVariants.all { variant ->
|
||||||
|
variant.outputs.all {
|
||||||
|
outputFileName = "ZenKanji-${variant.versionName}.apk"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
@@ -55,8 +67,6 @@ try {
|
|||||||
|
|
||||||
configurations.all {
|
configurations.all {
|
||||||
resolutionStrategy {
|
resolutionStrategy {
|
||||||
// Force the project to use the newer 1.8.22 versions
|
|
||||||
// The 1.8+ versions of jdk7/jdk8 are empty placeholders, fixing the duplication.
|
|
||||||
force 'org.jetbrains.kotlin:kotlin-stdlib:1.8.22'
|
force 'org.jetbrains.kotlin:kotlin-stdlib:1.8.22'
|
||||||
force 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.22'
|
force 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.22'
|
||||||
force 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.22'
|
force 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.22'
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
@@ -9,7 +8,6 @@
|
|||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/AppTheme"
|
android:theme="@style/AppTheme"
|
||||||
android:usesCleartextTraffic="true">
|
android:usesCleartextTraffic="true">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
@@ -17,12 +15,10 @@
|
|||||||
android:theme="@style/AppTheme.NoActionBarLaunch"
|
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
@@ -30,9 +26,7 @@
|
|||||||
android:authorities="${applicationId}.fileprovider"
|
android:authorities="${applicationId}.fileprovider"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:grantUriPermissions="true">
|
android:grantUriPermissions="true">
|
||||||
<meta-data
|
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
|
||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
|
||||||
android:resource="@xml/file_paths"></meta-data>
|
|
||||||
</provider>
|
</provider>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 15 KiB |
BIN
client/android/app/src/main/res/drawable-land-ldpi/splash.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 47 KiB |
BIN
client/android/app/src/main/res/drawable-night/splash.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 9.7 KiB |
BIN
client/android/app/src/main/res/drawable-port-ldpi/splash.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 5.6 KiB |
@@ -1,5 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@color/ic_launcher_background"/>
|
<background>
|
||||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
|
||||||
|
</background>
|
||||||
|
<foreground>
|
||||||
|
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
|
||||||
|
</foreground>
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@color/ic_launcher_background"/>
|
<background>
|
||||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
|
||||||
|
</background>
|
||||||
|
<foreground>
|
||||||
|
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
|
||||||
|
</foreground>
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 530 B |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 3.7 KiB |
BIN
client/android/app/src/main/res/mipmap-ldpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 278 B |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 349 B |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 700 B |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 12 KiB |
@@ -1,5 +1,3 @@
|
|||||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
|
||||||
|
|
||||||
buildscript {
|
buildscript {
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
@@ -9,9 +7,6 @@ buildscript {
|
|||||||
dependencies {
|
dependencies {
|
||||||
classpath 'com.android.tools.build:gradle:8.7.2'
|
classpath 'com.android.tools.build:gradle:8.7.2'
|
||||||
classpath 'com.google.gms:google-services:4.4.2'
|
classpath 'com.google.gms:google-services:4.4.2'
|
||||||
|
|
||||||
// NOTE: Do not place your application dependencies here; they belong
|
|
||||||
// in the individual module build.gradle files
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
# Project-wide Gradle settings.
|
|
||||||
|
|
||||||
# IDE (e.g. Android Studio) users:
|
|
||||||
# Gradle settings configured through the IDE *will override*
|
|
||||||
# any settings specified in this file.
|
|
||||||
|
|
||||||
# For more details on how to configure your build environment visit
|
|
||||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
|
||||||
|
|
||||||
# Specifies the JVM arguments used for the daemon process.
|
|
||||||
# The setting is particularly useful for tweaking memory settings.
|
|
||||||
org.gradle.jvmargs=-Xmx1536m
|
|
||||||
|
|
||||||
# When configured, Gradle will run in incubating parallel mode.
|
|
||||||
# This option should only be used with decoupled projects. More details, visit
|
|
||||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
|
||||||
# org.gradle.parallel=true
|
|
||||||
|
|
||||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
|
||||||
# Android operating system, and which are packaged with your app's APK
|
|
||||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
|
||||||
android.useAndroidX=true
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
"appName": "Zen Kanji",
|
"appName": "Zen Kanji",
|
||||||
"webDir": "dist",
|
"webDir": "dist",
|
||||||
"server": {
|
"server": {
|
||||||
"androidScheme": "http",
|
"androidScheme": "https",
|
||||||
"cleartext": true
|
"cleartext": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -442,19 +442,14 @@ async function triggerLogin() {
|
|||||||
try {
|
try {
|
||||||
const result = await store.login(inputKey.value.trim());
|
const result = await store.login(inputKey.value.trim());
|
||||||
|
|
||||||
// Wait for authentication state to settle
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// Step 1: Start the zoom animation on the login card/background
|
|
||||||
viewState.value = 'zooming';
|
viewState.value = 'zooming';
|
||||||
|
|
||||||
// Step 2: Only after a significant part of the zoom has happened,
|
|
||||||
// let the app content mount and start its own internal fade-in
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
viewState.value = 'app';
|
viewState.value = 'app';
|
||||||
if (result.user && !result.user.lastSync) {
|
if (result.user && !result.user.lastSync) {
|
||||||
manualSync();
|
manualSync();
|
||||||
}
|
}
|
||||||
}, 1000); // Increased delay to ensure zoom is the primary visual focus first
|
}, 1000);
|
||||||
}, 800);
|
}, 800);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -635,15 +630,11 @@ function confirmLogout() {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: scale(0.95);
|
transform: scale(0.95);
|
||||||
filter: blur(10px);
|
filter: blur(10px);
|
||||||
/* Increased blur for a smoother transition */
|
|
||||||
transition: all 1.2s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 1.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
/* Match the zoom ease */
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
/* Prevent clicks during transition */
|
|
||||||
|
|
||||||
&.app-entering {
|
&.app-entering {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
/* Keep hidden initially even if class is present */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.app-visible {
|
&.app-visible {
|
||||||
|
|||||||
@@ -69,9 +69,9 @@ const weeks = computed(() => {
|
|||||||
|
|
||||||
const getHeatmapClass = (count) => {
|
const getHeatmapClass = (count) => {
|
||||||
if (count === 0) return 'level-0';
|
if (count === 0) return 'level-0';
|
||||||
if (count <= 5) return 'level-1';
|
if (count <= 20) return 'level-1';
|
||||||
if (count <= 10) return 'level-2';
|
if (count <= 50) return 'level-2';
|
||||||
if (count <= 20) return 'level-3';
|
if (count <= 100) return 'level-3';
|
||||||
return 'level-4';
|
return 'level-4';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -23,14 +23,12 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
min-height: 50vh;
|
min-height: 50vh;
|
||||||
|
|
||||||
// Center the loader when state is loading
|
|
||||||
&.is-loading {
|
&.is-loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trigger animations for all items when data is ready
|
|
||||||
&.is-ready {
|
&.is-ready {
|
||||||
.slide-up-item {
|
.slide-up-item {
|
||||||
animation: dashboard-pop-in 0.6s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
|
animation: dashboard-pop-in 0.6s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
|
||||||
@@ -41,10 +39,8 @@
|
|||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Base state for animated items
|
|
||||||
.slide-up-item {
|
.slide-up-item {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
// Use the --delay variable passed from Vue, defaulting to 0ms
|
|
||||||
animation-delay: var(--delay, 0ms);
|
animation-delay: var(--delay, 0ms);
|
||||||
will-change: transform, opacity;
|
will-change: transform, opacity;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,11 +97,7 @@ export const processLesson = async (user, subjectId) => {
|
|||||||
if (!item) throw new Error('Item not found');
|
if (!item) throw new Error('Item not found');
|
||||||
|
|
||||||
item.srsLevel = 1;
|
item.srsLevel = 1;
|
||||||
|
item.nextReview = getSRSDate(1);
|
||||||
const nextReview = new Date();
|
|
||||||
nextReview.setUTCHours(nextReview.getUTCHours() + 2);
|
|
||||||
nextReview.setUTCMinutes(0, 0, 0);
|
|
||||||
item.nextReview = nextReview;
|
|
||||||
|
|
||||||
await item.save();
|
await item.save();
|
||||||
return { success: true, item };
|
return { success: true, item };
|
||||||
|
|||||||
@@ -73,6 +73,21 @@ describe('Controllers', () => {
|
|||||||
await ReviewController.submitReview(mockReq({}), reply);
|
await ReviewController.submitReview(mockReq({}), reply);
|
||||||
expect(reply.code).toHaveBeenCalledWith(404);
|
expect(reply.code).toHaveBeenCalledWith(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('submitLesson should succeed', async () => {
|
||||||
|
const reply = mockReply();
|
||||||
|
ReviewService.processLesson.mockResolvedValue({ success: true });
|
||||||
|
await ReviewController.submitLesson(mockReq({ subjectId: 100 }), reply);
|
||||||
|
expect(reply.send).toHaveBeenCalledWith({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submitLesson should handle error', async () => {
|
||||||
|
const reply = mockReply();
|
||||||
|
ReviewService.processLesson.mockRejectedValue(new Error('Lesson error'));
|
||||||
|
await ReviewController.submitLesson(mockReq({ subjectId: 100 }), reply);
|
||||||
|
expect(reply.code).toHaveBeenCalledWith(404);
|
||||||
|
expect(reply.send).toHaveBeenCalledWith({ error: 'Lesson error' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Sync Controller', () => {
|
describe('Sync Controller', () => {
|
||||||
@@ -113,6 +128,20 @@ describe('Controllers', () => {
|
|||||||
expect(ReviewService.getQueue).toHaveBeenCalledWith(expect.anything(), 50, undefined);
|
expect(ReviewService.getQueue).toHaveBeenCalledWith(expect.anything(), 50, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('getLessonQueue should call service with explicit limit', async () => {
|
||||||
|
const reply = mockReply();
|
||||||
|
ReviewService.getLessonQueue.mockResolvedValue([]);
|
||||||
|
await CollectionController.getLessonQueue(mockReq({}, {}, { limit: '10' }), reply);
|
||||||
|
expect(ReviewService.getLessonQueue).toHaveBeenCalledWith(expect.anything(), 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getLessonQueue should use default limit when none provided', async () => {
|
||||||
|
const reply = mockReply();
|
||||||
|
ReviewService.getLessonQueue.mockResolvedValue([]);
|
||||||
|
await CollectionController.getLessonQueue(mockReq({}, {}, {}), reply);
|
||||||
|
expect(ReviewService.getLessonQueue).toHaveBeenCalledWith(expect.anything(), 10);
|
||||||
|
});
|
||||||
|
|
||||||
it('getStats should call service', async () => {
|
it('getStats should call service', async () => {
|
||||||
const reply = mockReply();
|
const reply = mockReply();
|
||||||
StatsService.getUserStats.mockResolvedValue({});
|
StatsService.getUserStats.mockResolvedValue({});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import * as ReviewService from '../../src/services/review.service.js';
|
|||||||
import { User } from '../../src/models/User.js';
|
import { User } from '../../src/models/User.js';
|
||||||
import { ReviewLog } from '../../src/models/ReviewLog.js';
|
import { ReviewLog } from '../../src/models/ReviewLog.js';
|
||||||
import { StudyItem } from '../../src/models/StudyItem.js';
|
import { StudyItem } from '../../src/models/StudyItem.js';
|
||||||
|
import { getSRSDate } from '../../src/utils/dateUtils.js';
|
||||||
|
|
||||||
vi.mock('../../src/models/ReviewLog.js');
|
vi.mock('../../src/models/ReviewLog.js');
|
||||||
vi.mock('../../src/models/StudyItem.js');
|
vi.mock('../../src/models/StudyItem.js');
|
||||||
@@ -10,7 +11,7 @@ vi.mock('../../src/models/StudyItem.js');
|
|||||||
describe('Review Service', () => {
|
describe('Review Service', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.setSystemTime(new Date('2023-01-10T00:00:00Z'));
|
vi.setSystemTime(new Date('2023-01-10T12:00:00Z'));
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -47,50 +48,83 @@ describe('Review Service', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle standard success flow', async () => {
|
it('should handle standard success flow (Level 1 -> 2)', async () => {
|
||||||
const user = mockUser();
|
const user = mockUser();
|
||||||
delete user.stats;
|
delete user.stats;
|
||||||
StudyItem.findOne.mockResolvedValue(mockItem());
|
StudyItem.findOne.mockResolvedValue(mockItem());
|
||||||
const res = await ReviewService.processReview(user, 100, true);
|
|
||||||
|
const ZSRes = await ReviewService.processReview(user, 100, true);
|
||||||
|
|
||||||
expect(user.save).toHaveBeenCalled();
|
expect(user.save).toHaveBeenCalled();
|
||||||
expect(res.srsLevel).toBe(2);
|
expect(ZSRes.srsLevel).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle failure flow', async () => {
|
it('should handle failure flow (Level 2 -> 1)', async () => {
|
||||||
const user = mockUser();
|
const user = mockUser();
|
||||||
const item = mockItem(2);
|
const item = mockItem(2);
|
||||||
StudyItem.findOne.mockResolvedValue(item);
|
StudyItem.findOne.mockResolvedValue(item);
|
||||||
|
|
||||||
await ReviewService.processReview(user, 100, false);
|
await ReviewService.processReview(user, 100, false);
|
||||||
|
|
||||||
expect(item.srsLevel).toBe(1);
|
expect(item.srsLevel).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should increment streak (diff = 1)', async () => {
|
it('should increment streak (diff = 1)', async () => {
|
||||||
const user = mockUser({ lastStudyDate: '2023-01-09', currentStreak: 5 });
|
const user = mockUser({ lastStudyDate: '2023-01-09', currentStreak: 5 });
|
||||||
StudyItem.findOne.mockResolvedValue(mockItem());
|
StudyItem.findOne.mockResolvedValue(mockItem());
|
||||||
|
|
||||||
await ReviewService.processReview(user, 100, true);
|
await ReviewService.processReview(user, 100, true);
|
||||||
|
|
||||||
expect(user.stats.currentStreak).toBe(6);
|
expect(user.stats.currentStreak).toBe(6);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should maintain streak (diff = 0)', async () => {
|
it('should maintain streak (diff = 0)', async () => {
|
||||||
const user = mockUser({ lastStudyDate: '2023-01-10', currentStreak: 5 });
|
const user = mockUser({ lastStudyDate: '2023-01-10', currentStreak: 5 });
|
||||||
StudyItem.findOne.mockResolvedValue(mockItem());
|
StudyItem.findOne.mockResolvedValue(mockItem());
|
||||||
|
|
||||||
await ReviewService.processReview(user, 100, true);
|
await ReviewService.processReview(user, 100, true);
|
||||||
|
|
||||||
expect(user.stats.currentStreak).toBe(5);
|
expect(user.stats.currentStreak).toBe(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reset streak (diff > 1, no shield)', async () => {
|
it('should handle future lastStudyDate (diff < 1)', async () => {
|
||||||
|
const user = mockUser({ lastStudyDate: '2023-01-11', currentStreak: 5 });
|
||||||
|
StudyItem.findOne.mockResolvedValue(mockItem());
|
||||||
|
|
||||||
|
await ReviewService.processReview(user, 100, true);
|
||||||
|
|
||||||
|
expect(user.stats.currentStreak).toBe(5);
|
||||||
|
expect(user.stats.lastStudyDate).toBe('2023-01-10');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset streak (diff > 1, no shield available)', async () => {
|
||||||
const user = mockUser({ lastStudyDate: '2023-01-08', currentStreak: 5, lastFreezeDate: '2023-01-09' });
|
const user = mockUser({ lastStudyDate: '2023-01-08', currentStreak: 5, lastFreezeDate: '2023-01-09' });
|
||||||
StudyItem.findOne.mockResolvedValue(mockItem());
|
StudyItem.findOne.mockResolvedValue(mockItem());
|
||||||
|
|
||||||
await ReviewService.processReview(user, 100, true);
|
await ReviewService.processReview(user, 100, true);
|
||||||
|
|
||||||
expect(user.stats.currentStreak).toBe(1);
|
expect(user.stats.currentStreak).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use shield (diff = 2, shield ready)', async () => {
|
it('should use shield (diff = 2, shield ready)', async () => {
|
||||||
const user = mockUser({ lastStudyDate: '2023-01-08', currentStreak: 5, lastFreezeDate: '2022-01-01' });
|
const user = mockUser({ lastStudyDate: '2023-01-08', currentStreak: 5, lastFreezeDate: '2022-01-01' });
|
||||||
StudyItem.findOne.mockResolvedValue(mockItem());
|
StudyItem.findOne.mockResolvedValue(mockItem());
|
||||||
|
|
||||||
await ReviewService.processReview(user, 100, true);
|
await ReviewService.processReview(user, 100, true);
|
||||||
|
|
||||||
expect(user.stats.currentStreak).toBe(6);
|
expect(user.stats.currentStreak).toBe(6);
|
||||||
expect(user.stats.lastFreezeDate).toBeDefined();
|
expect(user.stats.lastFreezeDate).toBeDefined();
|
||||||
|
expect(user.stats.lastFreezeDate.toISOString()).toContain('2023-01-10');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT use shield if gap is too large (diff > 2)', async () => {
|
||||||
|
const user = mockUser({ lastStudyDate: '2023-01-07', currentStreak: 5, lastFreezeDate: '2022-01-01' });
|
||||||
|
StudyItem.findOne.mockResolvedValue(mockItem());
|
||||||
|
|
||||||
|
await ReviewService.processReview(user, 100, true);
|
||||||
|
|
||||||
|
expect(user.stats.currentStreak).toBe(1);
|
||||||
|
expect(new Date(user.stats.lastFreezeDate).toISOString()).toContain('2022-01-01');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use shield (diff = 2) when lastFreezeDate is undefined', async () => {
|
it('should use shield (diff = 2) when lastFreezeDate is undefined', async () => {
|
||||||
@@ -116,29 +150,79 @@ describe('Review Service', () => {
|
|||||||
expect(item.stats).toEqual(expect.objectContaining({ correct: 1, total: 1 }));
|
expect(item.stats).toEqual(expect.objectContaining({ correct: 1, total: 1 }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not break on time travel (diff < 0)', async () => {
|
it('should handle burning items (level 9 -> 10) and stop reviews', async () => {
|
||||||
const user = mockUser({ lastStudyDate: '2023-01-11', currentStreak: 5 });
|
const user = mockUser();
|
||||||
StudyItem.findOne.mockResolvedValue(mockItem());
|
const item = mockItem(9);
|
||||||
|
StudyItem.findOne.mockResolvedValue(item);
|
||||||
|
|
||||||
await ReviewService.processReview(user, 100, true);
|
const result = await ReviewService.processReview(user, 100, true);
|
||||||
|
|
||||||
expect(user.stats.lastStudyDate).toBe('2023-01-10');
|
expect(item.srsLevel).toBe(10);
|
||||||
expect(user.stats.currentStreak).toBe(5);
|
expect(item.nextReview).toBeNull();
|
||||||
|
expect(result.srsLevel).toBe(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('processLesson', () => {
|
||||||
|
const mockUser = () => ({ _id: 'u1' });
|
||||||
|
const mockItem = () => ({
|
||||||
|
userId: 'u1',
|
||||||
|
wkSubjectId: 100,
|
||||||
|
srsLevel: 0,
|
||||||
|
save: vi.fn(),
|
||||||
|
nextReview: null
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error if item not found', async () => {
|
||||||
|
StudyItem.findOne.mockResolvedValue(null);
|
||||||
|
await expect(ReviewService.processLesson(mockUser(), 999))
|
||||||
|
.rejects.toThrow('Item not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize lesson with correct SRS level and date (4 hours)', async () => {
|
||||||
|
const user = mockUser();
|
||||||
|
const item = mockItem();
|
||||||
|
StudyItem.findOne.mockResolvedValue(item);
|
||||||
|
|
||||||
|
const result = await ReviewService.processLesson(user, 100);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(item.srsLevel).toBe(1);
|
||||||
|
expect(item.save).toHaveBeenCalled();
|
||||||
|
|
||||||
|
const now = new Date('2023-01-10T12:00:00Z');
|
||||||
|
const expected = new Date(now.getTime() + 4 * 3600000);
|
||||||
|
expect(item.nextReview.toISOString()).toBe(expected.toISOString());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getQueue', () => {
|
describe('getQueue', () => {
|
||||||
it('should sort by priority', async () => {
|
it('should sort by priority', async () => {
|
||||||
const mockFind = { sort: vi.fn().mockReturnThis(), limit: vi.fn().mockResolvedValue([]) };
|
const mockChain = { sort: vi.fn().mockReturnThis(), limit: vi.fn().mockResolvedValue([]) };
|
||||||
StudyItem.find.mockReturnValue(mockFind);
|
StudyItem.find.mockReturnValue(mockChain);
|
||||||
await ReviewService.getQueue({ _id: 'u1' }, 10, 'priority');
|
await ReviewService.getQueue({ _id: 'u1' }, 10, 'priority');
|
||||||
expect(mockFind.sort).toHaveBeenCalled();
|
expect(mockChain.sort).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
it('should shuffle (default)', async () => {
|
|
||||||
const mockFind = { limit: vi.fn().mockResolvedValue([1, 2]) };
|
it('should shuffle (default behavior)', async () => {
|
||||||
StudyItem.find.mockReturnValue(mockFind);
|
const mockChain = { limit: vi.fn().mockResolvedValue([1, 2]) };
|
||||||
|
StudyItem.find.mockReturnValue(mockChain);
|
||||||
await ReviewService.getQueue({ _id: 'u1' }, 10, 'random');
|
await ReviewService.getQueue({ _id: 'u1' }, 10, 'random');
|
||||||
expect(mockFind.limit).toHaveBeenCalled();
|
expect(mockChain.limit).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getLessonQueue', () => {
|
||||||
|
it('should return sorted lesson queue', async () => {
|
||||||
|
const mockChain = { sort: vi.fn().mockReturnThis(), limit: vi.fn().mockResolvedValue(['l1', 'l2']) };
|
||||||
|
StudyItem.find.mockReturnValue(mockChain);
|
||||||
|
|
||||||
|
const result = await ReviewService.getLessonQueue({ _id: 'u1' }, 5);
|
||||||
|
|
||||||
|
expect(StudyItem.find).toHaveBeenCalledWith({ userId: 'u1', srsLevel: 0 });
|
||||||
|
expect(mockChain.sort).toHaveBeenCalledWith({ level: 1, wkSubjectId: 1 });
|
||||||
|
expect(mockChain.limit).toHaveBeenCalledWith(5);
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ describe('Sync Service', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should sync items in chunks', async () => {
|
it('should sync items in chunks', async () => {
|
||||||
const manyIds = Array.from({ length: 150 }, (_, i) => i + 1);
|
const manyIds = Array.from({ length: 100 }, (_, i) => i + 1);
|
||||||
const subjectData = manyIds.map(id => ({ data: { subject_id: id } }));
|
const subjectData = manyIds.map(id => ({ data: { subject_id: id } }));
|
||||||
|
|
||||||
fetch.mockResolvedValueOnce({
|
fetch.mockResolvedValueOnce({
|
||||||
@@ -202,11 +202,90 @@ describe('Sync Service', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
StudyItem.insertMany.mockResolvedValue(true);
|
StudyItem.insertMany.mockResolvedValue(true);
|
||||||
StudyItem.countDocuments.mockResolvedValue(150);
|
StudyItem.countDocuments.mockResolvedValue(100);
|
||||||
|
|
||||||
await syncWithWaniKani(mockUser);
|
await syncWithWaniKani(mockUser);
|
||||||
|
|
||||||
expect(fetch).toHaveBeenCalledTimes(3);
|
expect(fetch).toHaveBeenCalledTimes(3);
|
||||||
expect(StudyItem.insertMany).toHaveBeenCalledTimes(2);
|
expect(StudyItem.insertMany).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should fetch and map radicals for items, handling missing meanings', async () => {
|
||||||
|
fetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: [{ data: { subject_id: 500 } }],
|
||||||
|
pages: { next_url: null }
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
StudyItem.find.mockReturnValue({ select: vi.fn().mockResolvedValue([]) });
|
||||||
|
|
||||||
|
fetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: [{
|
||||||
|
id: 500,
|
||||||
|
data: {
|
||||||
|
characters: 'Kanji',
|
||||||
|
meanings: [{ primary: true, meaning: 'K_Mean' }],
|
||||||
|
level: 5,
|
||||||
|
readings: [],
|
||||||
|
component_subject_ids: [10, 11, 12]
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
fetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
data: {
|
||||||
|
characters: 'R1',
|
||||||
|
meanings: [{ primary: true, meaning: 'Rad1' }],
|
||||||
|
character_images: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 11,
|
||||||
|
data: {
|
||||||
|
characters: null,
|
||||||
|
meanings: [{ primary: true, meaning: 'Rad2' }],
|
||||||
|
character_images: [
|
||||||
|
{ content_type: 'image/png', url: 'bad.png', metadata: {} },
|
||||||
|
{ content_type: 'image/svg+xml', url: 'good.svg', metadata: { inline_styles: false } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 12,
|
||||||
|
data: {
|
||||||
|
characters: 'R2',
|
||||||
|
meanings: [{ primary: false, meaning: 'Secondary' }],
|
||||||
|
character_images: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
StudyItem.insertMany.mockResolvedValue(true);
|
||||||
|
StudyItem.countDocuments.mockResolvedValue(1);
|
||||||
|
|
||||||
|
await syncWithWaniKani(mockUser);
|
||||||
|
|
||||||
|
expect(StudyItem.insertMany).toHaveBeenCalledWith(expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
wkSubjectId: 500,
|
||||||
|
radicals: expect.arrayContaining([
|
||||||
|
expect.objectContaining({ meaning: 'Rad1', char: 'R1' }),
|
||||||
|
expect.objectContaining({ meaning: 'Rad2', image: 'good.svg' }),
|
||||||
|
expect.objectContaining({ meaning: 'Unknown', char: 'R2' })
|
||||||
|
])
|
||||||
|
})
|
||||||
|
]));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||