diff --git a/README.md b/README.md index e69de29..d6b101c 100644 --- a/README.md +++ b/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. diff --git a/client/.gitignore b/client/.gitignore index fa52c07..0401cea 100644 --- a/client/.gitignore +++ b/client/.gitignore @@ -1,3 +1,5 @@ node_modules .env .env.android +my-release-key.jks +gradle.properties diff --git a/client/android/app/build.gradle b/client/android/app/build.gradle index 0837fbb..266006c 100644 --- a/client/android/app/build.gradle +++ b/client/android/app/build.gradle @@ -11,17 +11,29 @@ android { versionName "1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 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:!*~' } } + signingConfigs { + release { + storeFile file("my-release-key.jks") + storePassword RELEASE_KEY_PASSWORD + keyAlias "my-key-alias" + keyPassword RELEASE_KEY_PASSWORD + } + } buildTypes { release { - minifyEnabled false + signingConfig signingConfigs.release + minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } } + applicationVariants.all { variant -> + variant.outputs.all { + outputFileName = "ZenKanji-${variant.versionName}.apk" + } + } } repositories { @@ -55,8 +67,6 @@ try { configurations.all { 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-jdk7:1.8.22' force 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.22' diff --git a/client/android/app/src/main/AndroidManifest.xml b/client/android/app/src/main/AndroidManifest.xml index 742c4c3..ea0ebc3 100644 --- a/client/android/app/src/main/AndroidManifest.xml +++ b/client/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,5 @@ - + - - - - - + diff --git a/client/android/app/src/main/res/drawable-land-hdpi/splash.png b/client/android/app/src/main/res/drawable-land-hdpi/splash.png index e31573b..d47e1f9 100644 Binary files a/client/android/app/src/main/res/drawable-land-hdpi/splash.png and b/client/android/app/src/main/res/drawable-land-hdpi/splash.png differ diff --git a/client/android/app/src/main/res/drawable-land-ldpi/splash.png b/client/android/app/src/main/res/drawable-land-ldpi/splash.png new file mode 100644 index 0000000..eaa253f Binary files /dev/null and b/client/android/app/src/main/res/drawable-land-ldpi/splash.png differ diff --git a/client/android/app/src/main/res/drawable-land-mdpi/splash.png b/client/android/app/src/main/res/drawable-land-mdpi/splash.png index f7a6492..d9e4a60 100644 Binary files a/client/android/app/src/main/res/drawable-land-mdpi/splash.png and b/client/android/app/src/main/res/drawable-land-mdpi/splash.png differ diff --git a/client/android/app/src/main/res/drawable-land-night-hdpi/splash.png b/client/android/app/src/main/res/drawable-land-night-hdpi/splash.png new file mode 100644 index 0000000..613df73 Binary files /dev/null and b/client/android/app/src/main/res/drawable-land-night-hdpi/splash.png differ diff --git a/client/android/app/src/main/res/drawable-land-night-ldpi/splash.png b/client/android/app/src/main/res/drawable-land-night-ldpi/splash.png new file mode 100644 index 0000000..a6c4d20 Binary files /dev/null and b/client/android/app/src/main/res/drawable-land-night-ldpi/splash.png differ diff --git a/client/android/app/src/main/res/drawable-land-night-mdpi/splash.png b/client/android/app/src/main/res/drawable-land-night-mdpi/splash.png new file mode 100644 index 0000000..8de748a Binary files /dev/null and b/client/android/app/src/main/res/drawable-land-night-mdpi/splash.png differ diff --git a/client/android/app/src/main/res/drawable-land-night-xhdpi/splash.png b/client/android/app/src/main/res/drawable-land-night-xhdpi/splash.png new file mode 100644 index 0000000..f82128c Binary files /dev/null and b/client/android/app/src/main/res/drawable-land-night-xhdpi/splash.png differ diff --git a/client/android/app/src/main/res/drawable-land-night-xxhdpi/splash.png b/client/android/app/src/main/res/drawable-land-night-xxhdpi/splash.png new file mode 100644 index 0000000..c8fcd1b Binary files /dev/null and b/client/android/app/src/main/res/drawable-land-night-xxhdpi/splash.png differ diff --git a/client/android/app/src/main/res/drawable-land-night-xxxhdpi/splash.png b/client/android/app/src/main/res/drawable-land-night-xxxhdpi/splash.png new file mode 100644 index 0000000..c0af599 Binary files /dev/null and b/client/android/app/src/main/res/drawable-land-night-xxxhdpi/splash.png differ diff --git a/client/android/app/src/main/res/drawable-land-xhdpi/splash.png b/client/android/app/src/main/res/drawable-land-xhdpi/splash.png index 8077255..0a23b3f 100644 Binary files a/client/android/app/src/main/res/drawable-land-xhdpi/splash.png and b/client/android/app/src/main/res/drawable-land-xhdpi/splash.png differ diff --git a/client/android/app/src/main/res/drawable-land-xxhdpi/splash.png b/client/android/app/src/main/res/drawable-land-xxhdpi/splash.png index 14c6c8f..0f83bbc 100644 Binary files a/client/android/app/src/main/res/drawable-land-xxhdpi/splash.png and b/client/android/app/src/main/res/drawable-land-xxhdpi/splash.png differ diff --git a/client/android/app/src/main/res/drawable-land-xxxhdpi/splash.png b/client/android/app/src/main/res/drawable-land-xxxhdpi/splash.png index 244ca25..f2cbe52 100644 Binary files a/client/android/app/src/main/res/drawable-land-xxxhdpi/splash.png and b/client/android/app/src/main/res/drawable-land-xxxhdpi/splash.png differ diff --git a/client/android/app/src/main/res/drawable-night/splash.png b/client/android/app/src/main/res/drawable-night/splash.png new file mode 100644 index 0000000..a6c4d20 Binary files /dev/null and b/client/android/app/src/main/res/drawable-night/splash.png differ diff --git a/client/android/app/src/main/res/drawable-port-hdpi/splash.png b/client/android/app/src/main/res/drawable-port-hdpi/splash.png index 74faaa5..9306f15 100644 Binary files a/client/android/app/src/main/res/drawable-port-hdpi/splash.png and b/client/android/app/src/main/res/drawable-port-hdpi/splash.png differ diff --git a/client/android/app/src/main/res/drawable-port-ldpi/splash.png b/client/android/app/src/main/res/drawable-port-ldpi/splash.png new file mode 100644 index 0000000..9e52924 Binary files /dev/null and b/client/android/app/src/main/res/drawable-port-ldpi/splash.png differ diff --git a/client/android/app/src/main/res/drawable-port-mdpi/splash.png b/client/android/app/src/main/res/drawable-port-mdpi/splash.png index e944f4a..ea2830a 100644 Binary files a/client/android/app/src/main/res/drawable-port-mdpi/splash.png and b/client/android/app/src/main/res/drawable-port-mdpi/splash.png differ diff --git a/client/android/app/src/main/res/drawable-port-night-hdpi/splash.png b/client/android/app/src/main/res/drawable-port-night-hdpi/splash.png new file mode 100644 index 0000000..9c398b8 Binary files /dev/null and b/client/android/app/src/main/res/drawable-port-night-hdpi/splash.png differ diff --git a/client/android/app/src/main/res/drawable-port-night-ldpi/splash.png b/client/android/app/src/main/res/drawable-port-night-ldpi/splash.png new file mode 100644 index 0000000..71f6d56 Binary files /dev/null and b/client/android/app/src/main/res/drawable-port-night-ldpi/splash.png differ diff --git a/client/android/app/src/main/res/drawable-port-night-mdpi/splash.png b/client/android/app/src/main/res/drawable-port-night-mdpi/splash.png new file mode 100644 index 0000000..038754a Binary files /dev/null and b/client/android/app/src/main/res/drawable-port-night-mdpi/splash.png differ diff --git a/client/android/app/src/main/res/drawable-port-night-xhdpi/splash.png b/client/android/app/src/main/res/drawable-port-night-xhdpi/splash.png new file mode 100644 index 0000000..72494e7 Binary files /dev/null and b/client/android/app/src/main/res/drawable-port-night-xhdpi/splash.png differ diff --git a/client/android/app/src/main/res/drawable-port-night-xxhdpi/splash.png b/client/android/app/src/main/res/drawable-port-night-xxhdpi/splash.png new file mode 100644 index 0000000..0850832 Binary files /dev/null and b/client/android/app/src/main/res/drawable-port-night-xxhdpi/splash.png differ diff --git a/client/android/app/src/main/res/drawable-port-night-xxxhdpi/splash.png b/client/android/app/src/main/res/drawable-port-night-xxxhdpi/splash.png new file mode 100644 index 0000000..e1f5a3b Binary files /dev/null and b/client/android/app/src/main/res/drawable-port-night-xxxhdpi/splash.png differ diff --git a/client/android/app/src/main/res/drawable-port-xhdpi/splash.png b/client/android/app/src/main/res/drawable-port-xhdpi/splash.png index 564a82f..767a601 100644 Binary files a/client/android/app/src/main/res/drawable-port-xhdpi/splash.png and b/client/android/app/src/main/res/drawable-port-xhdpi/splash.png differ diff --git a/client/android/app/src/main/res/drawable-port-xxhdpi/splash.png b/client/android/app/src/main/res/drawable-port-xxhdpi/splash.png index bfabe68..9d4a6f3 100644 Binary files a/client/android/app/src/main/res/drawable-port-xxhdpi/splash.png and b/client/android/app/src/main/res/drawable-port-xxhdpi/splash.png differ diff --git a/client/android/app/src/main/res/drawable-port-xxxhdpi/splash.png b/client/android/app/src/main/res/drawable-port-xxxhdpi/splash.png index 6929071..1c5781a 100644 Binary files a/client/android/app/src/main/res/drawable-port-xxxhdpi/splash.png and b/client/android/app/src/main/res/drawable-port-xxxhdpi/splash.png differ diff --git a/client/android/app/src/main/res/drawable/splash.png b/client/android/app/src/main/res/drawable/splash.png index f7a6492..ea2830a 100644 Binary files a/client/android/app/src/main/res/drawable/splash.png and b/client/android/app/src/main/res/drawable/splash.png differ diff --git a/client/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/client/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 036d09b..0aa82aa 100644 --- a/client/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/client/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,5 +1,9 @@ - - + + + + + + \ No newline at end of file diff --git a/client/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/client/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index 036d09b..0aa82aa 100644 --- a/client/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/client/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -1,5 +1,9 @@ - - + + + + + + \ No newline at end of file diff --git a/client/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/client/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index c023e50..d175930 100644 Binary files a/client/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/client/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/client/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png b/client/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png new file mode 100644 index 0000000..40fbc2a Binary files /dev/null and b/client/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png differ diff --git a/client/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/client/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png index 2127973..eb2e84d 100644 Binary files a/client/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and b/client/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/client/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/client/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png index b441f37..604c4c9 100644 Binary files a/client/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and b/client/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/client/android/app/src/main/res/mipmap-ldpi/ic_launcher.png b/client/android/app/src/main/res/mipmap-ldpi/ic_launcher.png new file mode 100644 index 0000000..d03f89f Binary files /dev/null and b/client/android/app/src/main/res/mipmap-ldpi/ic_launcher.png differ diff --git a/client/android/app/src/main/res/mipmap-ldpi/ic_launcher_background.png b/client/android/app/src/main/res/mipmap-ldpi/ic_launcher_background.png new file mode 100644 index 0000000..7536957 Binary files /dev/null and b/client/android/app/src/main/res/mipmap-ldpi/ic_launcher_background.png differ diff --git a/client/android/app/src/main/res/mipmap-ldpi/ic_launcher_foreground.png b/client/android/app/src/main/res/mipmap-ldpi/ic_launcher_foreground.png new file mode 100644 index 0000000..a0b4e84 Binary files /dev/null and b/client/android/app/src/main/res/mipmap-ldpi/ic_launcher_foreground.png differ diff --git a/client/android/app/src/main/res/mipmap-ldpi/ic_launcher_round.png b/client/android/app/src/main/res/mipmap-ldpi/ic_launcher_round.png new file mode 100644 index 0000000..ccb5cf3 Binary files /dev/null and b/client/android/app/src/main/res/mipmap-ldpi/ic_launcher_round.png differ diff --git a/client/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/client/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 72905b8..4136afe 100644 Binary files a/client/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/client/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/client/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png b/client/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png new file mode 100644 index 0000000..fa01e06 Binary files /dev/null and b/client/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png differ diff --git a/client/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/client/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png index 8ed0605..f8953bb 100644 Binary files a/client/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and b/client/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/client/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/client/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png index 9502e47..cee6f0b 100644 Binary files a/client/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and b/client/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/client/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/client/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 4d1e077..55d778e 100644 Binary files a/client/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/client/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/client/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png b/client/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png new file mode 100644 index 0000000..463eada Binary files /dev/null and b/client/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png differ diff --git a/client/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/client/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png index df0f158..6d726a0 100644 Binary files a/client/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and b/client/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/client/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/client/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png index 853db04..c72bf44 100644 Binary files a/client/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and b/client/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/client/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/client/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index 6cdf97c..2ceb024 100644 Binary files a/client/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/client/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/client/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png b/client/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png new file mode 100644 index 0000000..fc01ba7 Binary files /dev/null and b/client/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png differ diff --git a/client/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/client/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png index 2960cbb..6782dc8 100644 Binary files a/client/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and b/client/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/client/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/client/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png index 8e3093a..405ced6 100644 Binary files a/client/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and b/client/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/client/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/client/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 46de6e2..3a8e37a 100644 Binary files a/client/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/client/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/client/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png b/client/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png new file mode 100644 index 0000000..8e2952c Binary files /dev/null and b/client/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png differ diff --git a/client/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/client/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png index d2ea9ab..e74baf9 100644 Binary files a/client/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and b/client/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/client/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/client/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png index a40d73e..dad9917 100644 Binary files a/client/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and b/client/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/client/android/build.gradle b/client/android/build.gradle index f1b3b0e..adadaa7 100644 --- a/client/android/build.gradle +++ b/client/android/build.gradle @@ -1,5 +1,3 @@ -// Top-level build file where you can add configuration options common to all sub-projects/modules. - buildscript { repositories { @@ -9,9 +7,6 @@ buildscript { dependencies { classpath 'com.android.tools.build:gradle:8.7.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 } } diff --git a/client/android/gradle.properties b/client/android/gradle.properties deleted file mode 100644 index 2e87c52..0000000 --- a/client/android/gradle.properties +++ /dev/null @@ -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 diff --git a/client/capacitor.config.json b/client/capacitor.config.json index d57c1c8..5a765a2 100644 --- a/client/capacitor.config.json +++ b/client/capacitor.config.json @@ -3,7 +3,7 @@ "appName": "Zen Kanji", "webDir": "dist", "server": { - "androidScheme": "http", - "cleartext": true + "androidScheme": "https", + "cleartext": false } } diff --git a/client/src/App.vue b/client/src/App.vue index c1f8a79..888fb94 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -442,19 +442,14 @@ async function triggerLogin() { try { const result = await store.login(inputKey.value.trim()); - // Wait for authentication state to settle setTimeout(() => { - // Step 1: Start the zoom animation on the login card/background 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(() => { viewState.value = 'app'; if (result.user && !result.user.lastSync) { manualSync(); } - }, 1000); // Increased delay to ensure zoom is the primary visual focus first + }, 1000); }, 800); } catch (e) { console.error(e); @@ -635,15 +630,11 @@ function confirmLogout() { opacity: 0; transform: scale(0.95); filter: blur(10px); - /* Increased blur for a smoother transition */ transition: all 1.2s cubic-bezier(0.4, 0, 0.2, 1); - /* Match the zoom ease */ pointer-events: none; - /* Prevent clicks during transition */ &.app-entering { opacity: 0; - /* Keep hidden initially even if class is present */ } &.app-visible { diff --git a/client/src/components/dashboard/WidgetHeatmap.vue b/client/src/components/dashboard/WidgetHeatmap.vue index 7807bd2..05e29ef 100644 --- a/client/src/components/dashboard/WidgetHeatmap.vue +++ b/client/src/components/dashboard/WidgetHeatmap.vue @@ -69,9 +69,9 @@ const weeks = computed(() => { const getHeatmapClass = (count) => { if (count === 0) return 'level-0'; - if (count <= 5) return 'level-1'; - if (count <= 10) return 'level-2'; - if (count <= 20) return 'level-3'; + if (count <= 20) return 'level-1'; + if (count <= 50) return 'level-2'; + if (count <= 100) return 'level-3'; return 'level-4'; }; diff --git a/client/src/styles/pages/_dashboard.scss b/client/src/styles/pages/_dashboard.scss index 1005f3c..7422651 100644 --- a/client/src/styles/pages/_dashboard.scss +++ b/client/src/styles/pages/_dashboard.scss @@ -23,14 +23,12 @@ box-sizing: border-box; min-height: 50vh; - // Center the loader when state is loading &.is-loading { display: flex; align-items: center; justify-content: center; } - // Trigger animations for all items when data is ready &.is-ready { .slide-up-item { animation: dashboard-pop-in 0.6s cubic-bezier(0.2, 0.8, 0.2, 1) forwards; @@ -41,10 +39,8 @@ grid-column: 1 / -1; } - // Base state for animated items .slide-up-item { opacity: 0; - // Use the --delay variable passed from Vue, defaulting to 0ms animation-delay: var(--delay, 0ms); will-change: transform, opacity; } diff --git a/server/src/services/review.service.js b/server/src/services/review.service.js index 8850ed6..b173250 100644 --- a/server/src/services/review.service.js +++ b/server/src/services/review.service.js @@ -97,11 +97,7 @@ export const processLesson = async (user, subjectId) => { if (!item) throw new Error('Item not found'); item.srsLevel = 1; - - const nextReview = new Date(); - nextReview.setUTCHours(nextReview.getUTCHours() + 2); - nextReview.setUTCMinutes(0, 0, 0); - item.nextReview = nextReview; + item.nextReview = getSRSDate(1); await item.save(); return { success: true, item }; diff --git a/server/tests/services/controllers.test.js b/server/tests/services/controllers.test.js index 4f6153d..e7e5e98 100644 --- a/server/tests/services/controllers.test.js +++ b/server/tests/services/controllers.test.js @@ -73,6 +73,21 @@ describe('Controllers', () => { await ReviewController.submitReview(mockReq({}), reply); 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', () => { @@ -113,6 +128,20 @@ describe('Controllers', () => { 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 () => { const reply = mockReply(); StatsService.getUserStats.mockResolvedValue({}); diff --git a/server/tests/services/review.service.test.js b/server/tests/services/review.service.test.js index f68dc5c..55ef5c9 100644 --- a/server/tests/services/review.service.test.js +++ b/server/tests/services/review.service.test.js @@ -3,6 +3,7 @@ import * as ReviewService from '../../src/services/review.service.js'; import { User } from '../../src/models/User.js'; import { ReviewLog } from '../../src/models/ReviewLog.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/StudyItem.js'); @@ -10,7 +11,7 @@ vi.mock('../../src/models/StudyItem.js'); describe('Review Service', () => { beforeEach(() => { vi.useFakeTimers(); - vi.setSystemTime(new Date('2023-01-10T00:00:00Z')); + vi.setSystemTime(new Date('2023-01-10T12:00:00Z')); }); 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(); delete user.stats; 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(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 item = mockItem(2); StudyItem.findOne.mockResolvedValue(item); + await ReviewService.processReview(user, 100, false); + expect(item.srsLevel).toBe(1); }); it('should increment streak (diff = 1)', async () => { const user = mockUser({ lastStudyDate: '2023-01-09', currentStreak: 5 }); StudyItem.findOne.mockResolvedValue(mockItem()); + await ReviewService.processReview(user, 100, true); + expect(user.stats.currentStreak).toBe(6); }); it('should maintain streak (diff = 0)', async () => { const user = mockUser({ lastStudyDate: '2023-01-10', currentStreak: 5 }); StudyItem.findOne.mockResolvedValue(mockItem()); + await ReviewService.processReview(user, 100, true); + 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' }); StudyItem.findOne.mockResolvedValue(mockItem()); + await ReviewService.processReview(user, 100, true); + expect(user.stats.currentStreak).toBe(1); }); it('should use shield (diff = 2, shield ready)', async () => { const user = mockUser({ lastStudyDate: '2023-01-08', currentStreak: 5, lastFreezeDate: '2022-01-01' }); StudyItem.findOne.mockResolvedValue(mockItem()); + await ReviewService.processReview(user, 100, true); + expect(user.stats.currentStreak).toBe(6); 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 () => { @@ -116,29 +150,79 @@ describe('Review Service', () => { expect(item.stats).toEqual(expect.objectContaining({ correct: 1, total: 1 })); }); - it('should not break on time travel (diff < 0)', async () => { - const user = mockUser({ lastStudyDate: '2023-01-11', currentStreak: 5 }); - StudyItem.findOne.mockResolvedValue(mockItem()); + it('should handle burning items (level 9 -> 10) and stop reviews', async () => { + const user = mockUser(); + 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(user.stats.currentStreak).toBe(5); + expect(item.srsLevel).toBe(10); + 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', () => { it('should sort by priority', async () => { - const mockFind = { sort: vi.fn().mockReturnThis(), limit: vi.fn().mockResolvedValue([]) }; - StudyItem.find.mockReturnValue(mockFind); + const mockChain = { sort: vi.fn().mockReturnThis(), limit: vi.fn().mockResolvedValue([]) }; + StudyItem.find.mockReturnValue(mockChain); 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]) }; - StudyItem.find.mockReturnValue(mockFind); + + it('should shuffle (default behavior)', async () => { + const mockChain = { limit: vi.fn().mockResolvedValue([1, 2]) }; + StudyItem.find.mockReturnValue(mockChain); 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); }); }); }); diff --git a/server/tests/services/sync.service.test.js b/server/tests/services/sync.service.test.js index 27aa9c5..80af297 100644 --- a/server/tests/services/sync.service.test.js +++ b/server/tests/services/sync.service.test.js @@ -178,7 +178,7 @@ describe('Sync Service', () => { }); 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 } })); fetch.mockResolvedValueOnce({ @@ -202,11 +202,90 @@ describe('Sync Service', () => { }); StudyItem.insertMany.mockResolvedValue(true); - StudyItem.countDocuments.mockResolvedValue(150); + StudyItem.countDocuments.mockResolvedValue(100); await syncWithWaniKani(mockUser); expect(fetch).toHaveBeenCalledTimes(3); 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' }) + ]) + }) + ])); + }); });