This commit is contained in:
Rene Kievits
2025-12-18 01:30:52 +01:00
commit 6438660b03
78 changed files with 14230 additions and 0 deletions

34
.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
node_modules/
.env
.env.local
.env.*.local
zen_kanji_js/.env
zen_kanji_js/client/.env
zen_kanji_js/client/.env.android
dist/
dist-ssr/
*.local
.vscode/*
!.vscode/extensions.json
.idea/
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.DS_Store
Thumbs.db
coverage/
zen_kanji_js/client/android/

0
README.md Normal file
View File

2
client/.env.android Normal file
View File

@@ -0,0 +1,2 @@
VITE_API_URL=http://10.0.2.2:3000
CAP_ENV=dev

2
client/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules
.env

17
client/Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
FROM node:24-alpine AS dev-stage
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host"]
FROM dev-stage AS build-stage
ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL
RUN npm run build
FROM nginx:stable-alpine AS production-stage
COPY --from=build-stage /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,30 @@
/// <reference types="@capacitor/cli" />
require('dotenv').config();
const isDev = process.env.CAP_ENV === 'dev';
const config = {
appId: "com.zenkanji.app",
appName: "Zen Kanji",
webDir: "dist",
server: {
androidScheme: isDev ? "http" : "https",
cleartext: isDev
},
plugins: {
Keyboard: {
resize: "body",
style: "dark",
resizeOnFullScreen: true
},
SplashScreen: {
launchShowDuration: 2000,
backgroundColor: "#1e1e24",
showSpinner: false
}
}
};
console.log(`⚡️ Capacitor Config loaded for: ${isDev ? 'DEVELOPMENT (http)' : 'PRODUCTION (https)'}`);
module.exports = config;

69
client/eslint.config.js Normal file
View File

@@ -0,0 +1,69 @@
import js from '@eslint/js';
import globals from 'globals';
import pluginVue from 'eslint-plugin-vue';
import pluginVuePug from 'eslint-plugin-vue-pug';
import pluginJsonc from 'eslint-plugin-jsonc';
export default [
{
ignores: [
'**/node_modules/**',
'**/dist/**',
'**/android/**',
'**/coverage/**',
'**/*.min.js'
]
},
js.configs.recommended,
{
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: {
...globals.browser,
...globals.node,
...globals.es2021
}
},
rules: {
'no-unused-vars': 'warn',
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off'
}
},
...pluginVue.configs['flat/recommended'],
{
files: ['*.vue', '**/*.vue'],
plugins: {
'vue-pug': pluginVuePug
},
languageOptions: {
parser: pluginVue.parser,
parserOptions: {
parser: js.configs.recommended.parser,
ecmaVersion: 'latest',
sourceType: 'module',
}
},
rules: {
'vue/multi-word-component-names': 'off',
'vue/require-default-prop': 'off',
'vue/html-indent': ['error', 2],
'no-unused-vars': 'off',
'vue/component-name-in-template-casing': ['error', 'PascalCase', {
registeredComponentsOnly: true,
ignores: []
}]
}
},
...pluginJsonc.configs['flat/recommended-with-jsonc'],
{
files: ['*.json', '**/*.json'],
rules: {
'jsonc/sort-keys': 'off',
}
}
];

16
client/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Zen Kanji</title>
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

6658
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

47
client/package.json Normal file
View File

@@ -0,0 +1,47 @@
{
"name": "zen-kanji-client",
"version": "0.0.0",
"type": "module",
"scripts": {
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"lint:style": "stylelint \"**/*.{css,scss,vue}\"",
"lint:style:fix": "stylelint \"**/*.{css,scss,vue}\" --fix",
"dev": "vite",
"build": "vite build",
"build:android": "vite build --mode android",
"preview": "vite preview"
},
"dependencies": {
"@capacitor/android": "^8.0.0",
"@capacitor/core": "^8.0.0",
"@mdi/font": "^7.3.67",
"capacitor": "^0.5.6",
"i18n": "^0.15.3",
"pinia": "^3.0.4",
"pug": "^3.0.3",
"vue": "^3.3.4",
"vue-i18n": "^11.2.2",
"vue-pug": "^1.0.2",
"vue-pug-plugin": "^2.0.4",
"vue-router": "^4.2.5",
"vuetify": "^3.4.0"
},
"devDependencies": {
"@capacitor/cli": "^7.4.4",
"@eslint/js": "^9.39.2",
"@vitejs/plugin-vue": "^6.0.3",
"dotenv": "^17.2.3",
"eslint": "^9.39.2",
"eslint-plugin-jsonc": "^2.21.0",
"eslint-plugin-vue": "^9.33.0",
"eslint-plugin-vue-pug": "^0.6.2",
"globals": "^16.5.0",
"sass": "^1.97.0",
"stylelint": "^16.26.1",
"stylelint-config-recommended-vue": "^1.6.1",
"stylelint-config-standard-scss": "^16.0.0",
"vite": "^7.3.0",
"vue-eslint-parser": "^9.4.3"
}
}

348
client/src/App.vue Normal file
View File

@@ -0,0 +1,348 @@
<template lang="pug">
v-app.zen-app
v-navigation-drawer(
v-model="drawer"
temporary
location="right"
color="#1e1e24"
class="border-none"
width="280"
)
.d-flex.flex-column.h-100.pa-4
.d-flex.align-center.mb-6.px-2
img.mr-3(:src="logo" height="32" width="32" alt="Logo")
span.text-h6.font-weight-bold {{ $t('nav.menu') }}
v-list.pa-0(nav bg-color="transparent")
v-list-item.mb-2(
to="/"
rounded="lg"
:active="$route.path === '/'"
)
template(v-slot:prepend)
v-icon(color="#00cec9" icon="mdi-view-dashboard")
v-list-item-title.font-weight-bold {{ $t('nav.dashboard') }}
v-list-item.mb-2(
to="/collection"
rounded="lg"
:active="$route.path === '/collection'"
)
template(v-slot:prepend)
v-icon(color="#00cec9" icon="mdi-bookshelf")
v-list-item-title.font-weight-bold {{ $t('nav.collection') }}
v-divider.my-4.border-subtle
.d-flex.flex-column.gap-2
v-btn.justify-start.px-4(
variant="text"
block
color="grey-lighten-1"
@click="showSettings = true; drawer = false"
)
v-icon(start icon="mdi-cog")
| {{ $t('nav.settings') }}
v-btn.justify-start.px-4(
variant="text"
block
color="grey-lighten-1"
:loading="syncing"
@click="manualSync"
)
v-icon(start icon="mdi-sync")
| {{ $t('nav.sync') }}
v-btn.justify-start.px-4.text-red-lighten-2(
variant="text"
block
@click="handleLogout"
)
v-icon(start icon="mdi-logout")
| {{ $t('nav.logout') }}
v-app-bar.px-2.app-bar-blur(
flat
color="rgba(30, 30, 36, 0.8)"
border="b"
)
v-app-bar-title.font-weight-bold.text-h6(style="min-width: fit-content;")
.d-flex.align-center.cursor-pointer.logo-hover(@click="$router.push('/')")
img(:src="logo" height="32" width="32" alt="Zen Kanji Logo")
span.ml-3.tracking-tight Zen Kanji
v-spacer
template(v-if="store.token")
.d-none.d-md-flex.align-center
v-btn.mx-1(
to="/"
:active="false"
variant="text"
:color="$route.path === '/' ? '#00cec9' : 'grey'"
) {{ $t('nav.dashboard') }}
v-btn.mx-1(
to="/collection"
:active="false"
variant="text"
:color="$route.path === '/collection' ? '#00cec9' : 'grey'"
) {{ $t('nav.collection') }}
v-divider.mx-2.my-auto(vertical length="20" color="grey-darken-2")
v-tooltip(:text="$t('nav.settings')" location="bottom")
template(v-slot:activator="{ props }")
v-btn(
v-bind="props"
icon="mdi-cog"
variant="text"
color="grey-lighten-1"
@click="showSettings = true"
)
v-tooltip(:text="$t('nav.sync')" location="bottom")
template(v-slot:activator="{ props }")
v-btn(
v-bind="props"
icon="mdi-sync"
variant="text"
:loading="syncing"
color="grey-lighten-1"
@click="manualSync"
)
v-tooltip(:text="$t('nav.logout')" location="bottom")
template(v-slot:activator="{ props }")
v-btn(
v-bind="props"
icon="mdi-logout"
variant="text"
color="grey-darken-1"
@click="handleLogout"
)
.d-flex.d-md-none
v-btn(
icon="mdi-menu"
variant="text"
color="grey-lighten-1"
@click="drawer = !drawer"
)
v-main
.fill-height.d-flex.align-center.justify-center.px-4(v-if="!store.token")
v-card.pa-6.rounded-lg.elevation-10.text-center.border-subtle(
color="#1e1e24"
width="100%"
max-width="400"
)
img.mb-4(:src="logo" height="64" width="64")
h1.text-h5.font-weight-bold.mb-2 {{ $t('hero.welcome') }}
p.text-grey-lighten-1.text-body-2.mb-6 {{ $t('login.instruction') }}
v-text-field.mb-2(
v-model="inputKey"
:placeholder="$t('login.placeholder')"
variant="solo-filled"
bg-color="#2f3542"
color="white"
hide-details
density="comfortable"
@keyup.enter="handleLogin"
)
v-alert.mb-4.text-left.text-caption(
v-if="errorMsg"
type="error"
density="compact"
variant="tonal"
) {{ errorMsg }}
v-btn.text-black.font-weight-bold.mt-4(
block
color="#00cec9"
height="44"
:loading="loggingIn"
@click="handleLogin"
) {{ $t('login.button') }}
router-view(v-else)
v-dialog(v-model="showSettings" max-width="340")
v-card.rounded-xl.pa-4.border-subtle(color="#1e1e24")
v-card-title.text-center.font-weight-bold {{ $t('settings.title') }}
v-card-text.pt-4
.text-caption.text-grey.mb-2 {{ $t('settings.batchSize') }}
v-slider(
v-model="tempBatchSize"
:min="5"
:max="100"
:step="5"
thumb-label
color="#00cec9"
track-color="grey-darken-3"
)
.text-center.text-h5.font-weight-bold.text-teal-accent-3.mb-6
| {{ tempBatchSize }} {{ $t('settings.items') }}
.text-caption.text-grey.mb-2 Drawing Tolerance
v-slider(
v-model="tempDrawingAccuracy"
:min="5"
:max="20"
:step="1"
thumb-label
color="#00cec9"
track-color="grey-darken-3"
)
.d-flex.justify-space-between.text-caption.text-grey-lighten-1.mb-6.px-1
span Strict (5)
span.font-weight-bold.text-body-1(color="#00cec9") {{ tempDrawingAccuracy }}
span Loose (20)
.text-caption.text-grey.mb-2 {{ $t('settings.language') }}
v-btn-toggle.d-flex.w-100.border-subtle(
v-model="$i18n.locale"
mandatory
rounded="lg"
color="#00cec9"
)
v-btn.flex-grow-1(value="en") EN
v-btn.flex-grow-1(value="de") DE
v-btn.flex-grow-1(value="ja") JA
v-card-actions.mt-2
v-btn.text-white(
block
color="#2f3542"
@click="saveSettings"
) {{ $t('settings.save') }}
v-dialog(v-model="showLogoutDialog" max-width="320")
v-card.rounded-xl.pa-4.border-subtle(color="#1e1e24")
.d-flex.flex-column.align-center.text-center.pt-2
v-avatar.mb-3(color="red-darken-4" size="48")
v-icon(icon="mdi-logout" size="24" color="red-lighten-1")
h3.text-h6.font-weight-bold.mb-2 {{ $t('nav.logout') }}?
p.text-body-2.text-grey-lighten-1.px-2.mb-4
| {{ $t('alerts.logoutConfirm') }}
v-card-actions.d-flex.gap-2.pa-0.mt-2
v-btn.flex-grow-1.text-capitalize(
variant="tonal"
color="grey"
height="40"
@click="showLogoutDialog = false"
) {{ $t('common.cancel') }}
v-btn.flex-grow-1.text-capitalize(
color="red-accent-2"
variant="flat"
height="40"
@click="confirmLogout"
) {{ $t('nav.logout') }}
v-snackbar(v-model="snackbar.show" :color="snackbar.color" timeout="3000")
| {{ snackbar.text }}
template(v-slot:actions)
v-btn(variant="text" @click="snackbar.show = false") {{ $t('common.close') }}
</template>
<script setup>
import { ref, watch, onMounted } from 'vue';
import { useAppStore } from '@/stores/appStore';
import { useI18n } from 'vue-i18n';
import logo from '@/assets/icon.svg';
const drawer = ref(false);
const { locale, t } = useI18n();
const store = useAppStore();
const inputKey = ref('');
const loggingIn = ref(false);
const syncing = ref(false);
const errorMsg = ref('');
const showSettings = ref(false);
const showLogoutDialog = ref(false);
const snackbar = ref({ show: false, text: '', color: 'success' });
const tempBatchSize = ref(store.batchSize);
const tempDrawingAccuracy = ref(store.drawingAccuracy);
onMounted(() => {
if (store.token) {
store.fetchStats();
}
});
watch(showSettings, (isOpen) => {
if (isOpen) {
tempBatchSize.value = store.batchSize;
tempDrawingAccuracy.value = store.drawingAccuracy;
}
});
async function handleLogin() {
if (!inputKey.value) return;
loggingIn.value = true;
errorMsg.value = '';
try {
const result = await store.login(inputKey.value.trim());
if (result.user && !result.user.lastSync) {
manualSync();
}
} catch (e) {
console.error(e);
errorMsg.value = e.message || t('login.failed');
} finally {
loggingIn.value = false;
}
}
async function manualSync() {
syncing.value = true;
try {
const result = await store.sync();
snackbar.value = { show: true, text: t('alerts.syncSuccess', { count: result.count }), color: 'success' };
await store.fetchQueue();
await store.fetchStats();
await store.fetchCollection();
} catch (e) {
console.error(e);
snackbar.value = { show: true, text: t('alerts.syncFailed'), color: 'error' };
} finally {
syncing.value = false;
}
}
function saveSettings() {
store.saveSettings({
batchSize: tempBatchSize.value,
drawingAccuracy: tempDrawingAccuracy.value
});
localStorage.setItem('zen_locale', locale.value);
showSettings.value = false;
store.fetchQueue();
}
function handleLogout() {
showLogoutDialog.value = true;
drawer.value = false;
}
function confirmLogout() {
store.logout();
inputKey.value = '';
showLogoutDialog.value = false;
}
</script>
<style lang="scss" src="@/styles/pages/_app.scss"></style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 771 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 122 KiB

View File

@@ -0,0 +1,38 @@
<template lang="pug">
v-card.pa-4.rounded-xl.border-subtle.d-flex.align-center.justify-space-between(color="#1e1e24")
div
.text-subtitle-2.text-grey {{ $t('stats.accuracy') }}
.text-h3.font-weight-bold.text-white {{ accuracyPercent }}%
.text-caption.text-grey
| {{ accuracy?.correct || 0 }} {{ $t('stats.correct') }} / {{ accuracy?.total || 0 }} {{ $t('stats.total') }}
v-progress-circular(
:model-value="accuracyPercent"
color="#00cec9"
size="100"
width="10"
bg-color="grey-darken-3"
)
v-icon(color="#00cec9" size="large") mdi-bullseye-arrow
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
accuracy: {
type: Object,
default: () => ({
correct: 0,
total: 0
})
}
});
const accuracyPercent = computed(() => {
if (!props.accuracy || !props.accuracy.total) return 100;
return Math.round((props.accuracy.correct / props.accuracy.total) * 100);
});
</script>
<style lang="scss" src="@/styles/components/_widgets.scss" scoped></style>

View File

@@ -0,0 +1,73 @@
<template lang="pug">
v-card.pa-5.rounded-xl.border-subtle.d-flex.flex-column.flex-grow-1(color="#1e1e24")
.d-flex.align-center.justify-space-between.mb-4
.text-subtitle-1.font-weight-bold.d-flex.align-center
v-icon(color="#ffeaa7" start size="small") mdi-chart-bar
| {{ $t('stats.srsDistribution') }}
v-chip.font-weight-bold(
size="x-small"
color="white"
variant="tonal"
) {{ totalItems }}
.srs-chart-container.d-flex.justify-space-between.align-end.px-2.gap-2.flex-grow-1
.d-flex.flex-column.align-center.flex-grow-1.srs-column(
v-for="lvl in 10"
:key="lvl"
)
.text-caption.font-weight-bold.mb-2.transition-text(
:class="getCount(lvl) > 0 ? 'text-white' : 'text-grey-darken-3'"
) {{ getCount(lvl) }}
.srs-track
.srs-fill(:style="{\
height: getBarHeight(getCount(lvl)) + '%',\
background: getSRSColor(lvl),\
boxShadow: getCount(lvl) > 0 ? `0 0 20px ${getSRSColor(lvl)}30` : 'none'\
}")
.text-caption.text-grey-darken-1.font-weight-bold.mt-3(
style="font-size: 10px !important;"
) {{ toRoman(lvl) }}
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
distribution: {
type: Object,
default: () => ({})
}
});
const totalItems = computed(() => Object.values(props.distribution || {}).reduce((a, b) => a + b, 0));
const getCount = (lvl) => props.distribution?.[lvl] || 0;
const getBarHeight = (count) => {
const max = Math.max(...Object.values(props.distribution || {}), 1);
if (count > 0 && (count / max) * 100 < 4) return 4;
return (count / max) * 100;
};
const getSRSColor = (lvl) => {
const colors = {
1: '#ff7675', 2: '#fdcb6e', 3: '#55efc4',
4: '#0984e3', 5: '#a29bfe', 6: '#6c5ce7',
7: '#00cec9', 8: '#fd79a8', 9: '#e84393',
10: '#ffd700'
};
return colors[lvl] || '#444';
};
const toRoman = (num) => {
const lookup = {
1: 'I', 2: 'II', 3: 'III', 4: 'IV', 5: 'V',
6: 'VI', 7: 'VII', 8: 'VIII', 9: 'IX', 10: 'X'
};
return lookup[num] || num;
};
</script>
<style lang="scss" src="@/styles/components/_widgets.scss" scoped></style>

View File

@@ -0,0 +1,41 @@
<template lang="pug">
v-card.pa-4.rounded-xl.border-subtle.d-flex.flex-column.flex-grow-1(color="#1e1e24")
.text-subtitle-1.font-weight-bold.mb-3.d-flex.align-center
v-icon(color="#ffeaa7" start) mdi-clock-outline
| {{ $t('stats.next24') }}
.forecast-list.flex-grow-1(v-if="hasUpcoming")
div(v-for="(count, hour) in forecast" :key="hour")
.d-flex.justify-space-between.align-center.mb-2.py-2.border-b-subtle(v-if="count > 0")
span.text-body-2.text-grey-lighten-1
| {{ hour === 0 ? $t('stats.availableNow') : $t('stats.inHours', { n: hour }, hour) }}
v-chip.font-weight-bold(
size="small"
color="#2f3542"
style="color: #00cec9 !important;"
) {{ count }}
.fill-height.d-flex.align-center.justify-center.text-grey.text-center.pa-4(v-else)
| {{ $t('stats.noIncoming') }}
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
forecast: {
type: Object,
default: () => ({})
}
});
const hasUpcoming = computed(() => {
return props.forecast && props.forecast.some(c => c > 0);
});
</script>
<style lang="scss" src="@/styles/components/_widgets.scss" scoped>
.border-b-subtle {
border-bottom: 1px solid rgb(255 255 255 / 5%);
}
</style>

View File

@@ -0,0 +1,32 @@
<template lang="pug">
v-card.pa-4.rounded-xl.border-subtle(color="#1e1e24")
.d-flex.justify-space-between.align-center.mb-3
.d-flex.align-center
v-icon(color="#ff7675" start size="small") mdi-ghost
div
.text-subtitle-2.text-white {{ $t('stats.ghostTitle') }}
.text-caption.text-grey {{ $t('stats.ghostSubtitle') }}
.d-flex.justify-space-between.gap-2(v-if="ghosts && ghosts.length > 0")
.ghost-card.flex-grow-1(v-for="ghost in ghosts" :key="ghost._id")
.text-h6.font-weight-bold.text-white.mb-1 {{ ghost.char }}
v-chip.font-weight-bold.text-black.w-100.justify-center(
size="x-small"
color="red-accent-2"
variant="flat"
) {{ ghost.accuracy }}%
.text-center.py-2.text-caption.text-grey(v-else)
| {{ $t('stats.noGhosts') }}
</template>
<script setup>
const props = defineProps({
ghosts: {
type: Array,
default: () => []
}
});
</script>
<style lang="scss" src="@/styles/components/_widgets.scss" scoped></style>

View File

@@ -0,0 +1,45 @@
<template lang="pug">
v-card.widget-card.pa-5.rounded-xl.d-flex.flex-column.justify-center(color="#1e1e24" flat)
.d-flex.justify-space-between.align-center.mb-2
.d-flex.align-center
v-icon(color="secondary" start size="small") mdi-trophy-outline
span.text-subtitle-2.font-weight-bold {{ $t('stats.mastery') }}
.text-subtitle-2.text-white.font-weight-bold {{ masteryPercent }}%
v-progress-linear(
:model-value="masteryPercent"
color="primary"
height="8"
rounded
bg-color="grey-darken-3"
striped
)
.text-caption.text-medium-emphasis.mt-2.text-right
| {{ masteredCount }} / {{ totalItems }} {{ $t('stats.items') }}
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
distribution: {
type: Object,
default: () => ({})
}
});
const totalItems = computed(() => Object.values(props.distribution || {}).reduce((a, b) => a + b, 0));
const masteredCount = computed(() => {
const dist = props.distribution || {};
return (dist[6] || 0);
});
const masteryPercent = computed(() => {
if (totalItems.value === 0) return 0;
return Math.round((masteredCount.value / totalItems.value) * 100);
});
</script>
<style lang="scss" src="@/styles/components/_widgets.scss" scoped></style>

View File

@@ -0,0 +1,89 @@
<template lang="pug">
v-card.widget-card.pa-4.rounded-xl(color="#1e1e24" flat)
.d-flex.flex-wrap.justify-space-between.align-center.mb-4.gap-2
.text-subtitle-1.font-weight-bold.d-flex.align-center
v-icon(color="secondary" start) mdi-calendar-check
| {{ $t('stats.consistency') }}
.legend-container
span.text-caption.text-medium-emphasis.mr-1 {{ $t('stats.less') }}
.legend-box.level-0
.legend-box.level-1
.legend-box.level-2
.legend-box.level-3
.legend-box.level-4
span.text-caption.text-medium-emphasis.ml-1 {{ $t('stats.more') }}
.heatmap-container
.heatmap-year
.heatmap-week(v-for="(week, wIdx) in weeks" :key="wIdx")
.heatmap-day-wrapper(v-for="(day, dIdx) in week" :key="dIdx")
v-tooltip(location="top" open-delay="100")
template(v-slot:activator="{ props }")
.heatmap-cell(
v-bind="props"
:class="[\
isToday(day.date) ? 'today-cell' : '',\
getHeatmapClass(day.count)\
]"
)
.text-center
.font-weight-bold {{ formatDate(day.date) }}
div {{ $t('stats.reviewsCount', { count: day.count }) }}
</template>
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const { locale } = useI18n();
const props = defineProps({
heatmapData: {
type: Object,
default: () => ({})
}
});
const weeks = computed(() => {
const data = props.heatmapData || {};
const w = [];
const today = new Date();
const startDate = new Date(today);
startDate.setDate(today.getDate() - (52 * 7));
const dayOfWeek = startDate.getDay();
startDate.setDate(startDate.getDate() - dayOfWeek);
let currentWeek = [];
for (let i = 0; i < 371; i++) {
const d = new Date(startDate);
d.setDate(startDate.getDate() + i);
const dateStr = d.toISOString().split('T')[0];
currentWeek.push({
date: dateStr,
count: data[dateStr] || 0
});
if (currentWeek.length === 7) {
w.push(currentWeek);
currentWeek = [];
}
}
return w;
});
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';
return 'level-4';
};
const formatDate = (dateStr) => new Date(dateStr).toLocaleDateString(locale.value, { month: 'short', day: 'numeric' });
const isToday = (dateStr) => dateStr === new Date().toISOString().split('T')[0];
</script>
<style lang="scss" src="@/styles/components/_widgets.scss" scoped></style>

View File

@@ -0,0 +1,59 @@
<template lang="pug">
v-card.pa-4.rounded-xl.border-subtle(color="#1e1e24")
.d-flex.justify-space-between.align-start.mb-2
div
.text-subtitle-2.text-grey {{ $t('stats.streakTitle') }}
.d-flex.align-center
.text-h3.font-weight-bold.text-white.mr-2 {{ streak?.current || 0 }}
.text-h6.text-grey {{ $t('stats.days') }}
.text-center
v-tooltip(
location="start"
:text="streak?.shield?.ready ? $t('stats.shieldActive') : $t('stats.shieldCooldown', { n: streak?.shield?.cooldown })"
)
template(v-slot:activator="{ props }")
v-avatar(
v-bind="props"
size="48"
:color="streak?.shield?.ready ? 'rgba(0, 206, 201, 0.1)' : 'rgba(255, 255, 255, 0.05)'"
style="border: 1px solid;"
:style="{ borderColor: streak?.shield?.ready ? '#00cec9' : '#555' }"
)
v-icon(:color="streak?.shield?.ready ? '#00cec9' : 'grey'")
| {{ streak?.shield?.ready ? 'mdi-shield-check' : 'mdi-shield-off-outline' }}
.d-flex.justify-space-between.align-center.mt-2.px-1
.d-flex.flex-column.align-center(
v-for="(day, idx) in (streak?.history || [])"
:key="idx"
)
.streak-dot.mb-1(:class="{ 'active': day.active }")
v-icon(v-if="day.active" size="12" color="black") mdi-check
.text-grey.text-uppercase(style="font-size: 10px;")
| {{ getDayLabel(day.date) }}
</template>
<script setup>
import { useI18n } from 'vue-i18n';
const props = defineProps({
streak: {
type: Object,
required: true,
default: () => ({
current: 0,
history: [],
shield: { ready: false, cooldown: 0 }
})
}
});
const { locale } = useI18n();
const getDayLabel = (dateStr) => {
if (!dateStr) return '';
return new Date(dateStr).toLocaleDateString(locale.value, { weekday: 'short' });
};
</script>
<style lang="scss" src="@/styles/components/_widgets.scss" scoped></style>

View File

@@ -0,0 +1,76 @@
<template lang="pug">
.grid-area-full.text-center.mb-4
.text-h2.font-weight-bold.mb-2 {{ $t('hero.welcome') }}
.text-h5.text-grey.mb-8 {{ $t('hero.subtitle') }}
.d-flex.justify-center.align-center.flex-column
v-btn.text-h5.font-weight-bold.text-black.glow-btn(
@click="$emit('start', 'shuffled')"
height="80"
width="280"
rounded="xl"
color="#00cec9"
:disabled="queueLength === 0"
)
v-icon(size="32" start) mdi-brush
| {{ queueLength > 0 ? $t('hero.start') : $t('hero.noReviews') }}
v-chip.ml-3.font-weight-bold(
v-if="queueLength > 0"
color="#1e1e24"
variant="flat"
size="default"
style="color: white !important;"
) {{ queueLength }}
v-fade-transition
v-btn.mt-4.text-caption.font-weight-bold(
v-if="hasLowerLevels"
@click="$emit('start', 'priority')"
variant="plain"
color="grey-lighten-1"
prepend-icon="mdi-sort-ascending"
:ripple="false"
) {{ $t('hero.prioritize', { count: lowerLevelCount }) }}
.text-caption.text-grey.mt-2(v-if="queueLength === 0")
| {{ $t('hero.nextIn') }} {{ nextReviewTime }}
</template>
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
queueLength: {
type: Number,
required: true
},
hasLowerLevels: {
type: Boolean,
default: false
},
lowerLevelCount: {
type: Number,
default: 0
},
forecast: {
type: Object,
default: () => ({})
}
});
defineEmits(['start']);
const { t } = useI18n();
const nextReviewTime = computed(() => {
if (!props.forecast) return "a while";
const idx = props.forecast.findIndex(c => c > 0);
if (idx === -1) return "a while";
return idx === 0 ? t('hero.now') : t('stats.inHours', { n: idx }, idx);
});
</script>
<style lang="scss" src="@/styles/components/_buttons.scss" scoped></style>

View File

@@ -0,0 +1,247 @@
<template lang="pug">
.canvas-container
.loading-text(v-if="loading") {{ $t('review.loading') }}
.canvas-wrapper(ref="wrapper")
canvas(
ref="bgCanvas"
:width="CANVAS_SIZE"
:height="CANVAS_SIZE"
)
canvas(
ref="snapCanvas"
:width="CANVAS_SIZE"
:height="CANVAS_SIZE"
)
canvas(
ref="drawCanvas"
:width="CANVAS_SIZE"
:height="CANVAS_SIZE"
@mousedown="startDraw"
@mousemove="draw"
@mouseup="endDraw"
@mouseleave="endDraw"
@touchstart.prevent="startDraw"
@touchmove.prevent="draw"
@touchend.prevent="endDraw"
)
</template>
<script setup>
import { ref, onMounted, watch } from 'vue';
import { useAppStore } from '@/stores/appStore';
const props = defineProps({
char: {
type: String,
required: true
}
});
const emit = defineEmits(['complete', 'mistake']);
const store = useAppStore();
const KANJI_SIZE = 109;
const CANVAS_SIZE = 300;
const SCALE = CANVAS_SIZE / KANJI_SIZE;
const wrapper = ref(null);
const bgCanvas = ref(null);
const snapCanvas = ref(null);
const drawCanvas = ref(null);
let ctxBg, ctxSnap, ctxDraw;
const kanjiPaths = ref([]);
const currentStrokeIndex = ref(0);
const failureCount = ref(0);
const loading = ref(false);
let isDrawing = false;
let userPath = [];
onMounted(() => {
initContexts();
if (props.char) loadKanji(props.char);
});
watch(() => props.char, (newChar) => {
if (newChar) loadKanji(newChar);
});
function initContexts() {
ctxBg = bgCanvas.value.getContext('2d');
ctxSnap = snapCanvas.value.getContext('2d');
ctxDraw = drawCanvas.value.getContext('2d');
[ctxBg, ctxSnap, ctxDraw].forEach(ctx => {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(SCALE, SCALE);
ctx.lineCap = "round";
ctx.lineJoin = "round";
});
}
async function loadKanji(char) {
reset();
loading.value = true;
const hex = char.charCodeAt(0).toString(16).padStart(5, '0');
try {
const res = await fetch(`https://raw.githubusercontent.com/KanjiVG/kanjivg/master/kanji/${hex}.svg`);
const txt = await res.text();
const parser = new DOMParser();
const doc = parser.parseFromString(txt, "image/svg+xml");
kanjiPaths.value = Array.from(doc.getElementsByTagName("path")).map(p => p.getAttribute("d"));
drawGuide();
} catch (e) {
console.error("Failed to load KanjiVG data", e);
} finally {
loading.value = false;
}
}
function reset() {
currentStrokeIndex.value = 0;
failureCount.value = 0;
kanjiPaths.value = [];
[ctxBg, ctxSnap, ctxDraw].forEach(ctx => {
ctx.clearRect(0, 0, KANJI_SIZE, KANJI_SIZE);
});
}
function getCoords(e) {
const rect = drawCanvas.value.getBoundingClientRect();
const cx = e.touches ? e.touches[0].clientX : e.clientX;
const cy = e.touches ? e.touches[0].clientY : e.clientY;
return {
x: (cx - rect.left) / SCALE,
y: (cy - rect.top) / SCALE
};
}
function startDraw(e) {
if (currentStrokeIndex.value >= kanjiPaths.value.length) return;
isDrawing = true;
userPath = [];
const p = getCoords(e);
userPath.push(p);
ctxDraw.beginPath();
ctxDraw.moveTo(p.x, p.y);
ctxDraw.strokeStyle = '#ff7675';
ctxDraw.lineWidth = 4;
}
function draw(e) {
if (!isDrawing) return;
const p = getCoords(e);
userPath.push(p);
ctxDraw.lineTo(p.x, p.y);
ctxDraw.stroke();
}
function endDraw() {
if (!isDrawing) return;
isDrawing = false;
const targetD = kanjiPaths.value[currentStrokeIndex.value];
if (checkMatch(userPath, targetD)) {
ctxSnap.strokeStyle = '#00cec9';
ctxSnap.lineWidth = 4;
ctxSnap.stroke(new Path2D(targetD));
currentStrokeIndex.value++;
failureCount.value = 0;
ctxDraw.clearRect(0, 0, KANJI_SIZE, KANJI_SIZE);
if (currentStrokeIndex.value >= kanjiPaths.value.length) {
emit('complete');
} else {
drawGuide();
}
} else {
failureCount.value++;
ctxDraw.clearRect(0, 0, KANJI_SIZE, KANJI_SIZE);
if (failureCount.value >= 3) {
drawGuide(true);
emit('mistake', true);
} else {
emit('mistake', false);
}
}
}
function checkMatch(userPts, targetD) {
if (userPts.length < 5) return false;
const tempPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
tempPath.setAttribute("d", targetD);
const len = tempPath.getTotalLength();
const targetEnd = tempPath.getPointAtLength(len);
const userEnd = userPts[userPts.length - 1];
const threshold = store.drawingAccuracy || 10;
const dist = (p1, p2) => Math.hypot(p1.x - p2.x, p1.y - p2.y);
if (dist(userEnd, targetEnd) > threshold * 3.0) return false;
let totalError = 0;
const samples = 10;
for (let i = 0; i <= samples; i++) {
const pt = tempPath.getPointAtLength((i / samples) * len);
let min = Infinity;
for (let p of userPts) min = Math.min(min, dist(pt, p));
totalError += min;
}
return (totalError / (samples + 1)) < threshold;
}
function drawGuide(showHint = false) {
ctxBg.clearRect(0, 0, KANJI_SIZE, KANJI_SIZE);
if (!showHint) return;
const d = kanjiPaths.value[currentStrokeIndex.value];
if (!d) return;
ctxBg.strokeStyle = '#57606f';
ctxBg.lineWidth = 3;
ctxBg.setLineDash([5, 5]);
ctxBg.stroke(new Path2D(d));
ctxBg.setLineDash([]);
const tempPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
tempPath.setAttribute("d", d);
const len = tempPath.getTotalLength();
const mid = tempPath.getPointAtLength(len / 2);
const prev = tempPath.getPointAtLength(Math.max(0, (len / 2) - 1));
const angle = Math.atan2(mid.y - prev.y, mid.x - prev.x);
ctxBg.save();
ctxBg.translate(mid.x, mid.y);
ctxBg.rotate(angle);
ctxBg.strokeStyle = 'rgba(255, 234, 167, 0.7)';
ctxBg.lineWidth = 2;
ctxBg.lineCap = 'round';
ctxBg.lineJoin = 'round';
ctxBg.beginPath();
ctxBg.moveTo(-7, 0);
ctxBg.lineTo(2, 0);
ctxBg.moveTo(-1, -3);
ctxBg.lineTo(2, 0);
ctxBg.lineTo(-1, 3);
ctxBg.stroke();
ctxBg.restore();
}
defineExpose({ drawGuide });
</script>
<style lang="scss" src="@/styles/components/_kanji.scss" scoped></style>

View File

@@ -0,0 +1,138 @@
<template lang="pug">
.svg-container(:class="{ loading: loading }")
svg.kanji-svg(
v-if="!loading"
viewBox="0 0 109 109"
width="100%"
height="100%"
)
g(v-for="(stroke, i) in strokes" :key="i")
path.stroke-path(
:d="stroke.d"
:class="{\
'animating': isPlaying && currentStrokeIdx === i,\
'hidden': isPlaying && currentStrokeIdx < i,\
'drawn': isPlaying && currentStrokeIdx > i\
}"
:style="{\
'--len': stroke.len,\
'--duration': (stroke.len * 0.02) + 's'\
}"
)
g(v-show="!isPlaying || currentStrokeIdx > i")
circle.stroke-start-circle(
v-if="stroke.start"
:cx="stroke.start.x"
:cy="stroke.start.y"
r="3.5"
)
text.stroke-number(
v-if="stroke.start"
:x="stroke.start.x"
:y="stroke.start.y + 0.5"
) {{ i + 1 }}
path.stroke-arrow-line(
v-if="stroke.arrow"
d="M -7 0 L 2 0 M -1 -3 L 2 0 L -1 3"
:transform="`translate(${stroke.arrow.x}, ${stroke.arrow.y}) rotate(${stroke.arrow.angle})`"
)
.loading-spinner(v-else) {{ $t('review.loading') }}
button.play-btn(
v-if="!loading && !isPlaying"
@click="playAnimation"
)
svg(viewBox="0 0 24 24" fill="currentColor")
path(d="M8 5v14l11-7z")
</template>
<script setup>
import { ref, watch, onMounted } from 'vue';
const props = defineProps({
char: {
type: String,
required: true
}
});
const strokes = ref([]);
const loading = ref(true);
const isPlaying = ref(false);
const currentStrokeIdx = ref(-1);
onMounted(() => {
if (props.char) loadData(props.char);
});
watch(() => props.char, (newChar) => {
if (newChar) loadData(newChar);
});
async function loadData(char) {
loading.value = true;
isPlaying.value = false;
strokes.value = [];
const hex = char.charCodeAt(0).toString(16).padStart(5, '0');
try {
const res = await fetch(`https://raw.githubusercontent.com/KanjiVG/kanjivg/master/kanji/${hex}.svg`);
const txt = await res.text();
const parser = new DOMParser();
const doc = parser.parseFromString(txt, "image/svg+xml");
const rawPaths = Array.from(doc.getElementsByTagName("path")).map(p => p.getAttribute("d"));
strokes.value = rawPaths.map(d => {
const data = { d, start: null, arrow: null, len: 0 };
const tempPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
tempPath.setAttribute("d", d);
try {
data.len = tempPath.getTotalLength();
} catch (e) { data.len = 100; }
const startMatch = d.match(/[Mm]\s*([\d.]+)[,\s]([\d.]+)/);
if (startMatch) {
data.start = { x: parseFloat(startMatch[1]), y: parseFloat(startMatch[2]) };
}
try {
const mid = tempPath.getPointAtLength(data.len / 2);
const prev = tempPath.getPointAtLength(Math.max(0, (data.len / 2) - 1));
const angle = Math.atan2(mid.y - prev.y, mid.x - prev.x) * (180 / Math.PI);
data.arrow = { x: mid.x, y: mid.y, angle };
} catch (e) { console.error(e) }
return data;
});
} catch (e) {
console.error("SVG Load Failed", e);
} finally {
loading.value = false;
}
}
async function playAnimation() {
if (isPlaying.value) return;
isPlaying.value = true;
currentStrokeIdx.value = -1;
await new Promise(r => setTimeout(r, 200));
for (let i = 0; i < strokes.value.length; i++) {
currentStrokeIdx.value = i;
const duration = strokes.value[i].len * 20;
await new Promise(r => setTimeout(r, duration + 100));
}
await new Promise(r => setTimeout(r, 500));
isPlaying.value = false;
}
</script>
<style lang="scss" src="@/styles/components/_kanji.scss" scoped></style>

49
client/src/main.js Normal file
View File

@@ -0,0 +1,49 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { createRouter, createWebHistory } from 'vue-router'
import i18n from '@/plugins/i18n'
import '@/styles/main.scss'
import 'vuetify/styles'
import { createVuetify } from 'vuetify'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'
import { aliases, mdi } from 'vuetify/iconsets/mdi'
import '@mdi/font/css/materialdesignicons.css'
import App from './App.vue'
import Dashboard from './views/Dashboard.vue'
import Collection from './views/Collection.vue'
import Review from './views/Review.vue'
const app = createApp(App)
const pinia = createPinia()
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: Dashboard },
{ path: '/dashboard', component: Dashboard },
{ path: '/collection', component: Collection },
{ path: '/review', component: Review }
]
})
const vuetify = createVuetify({
components,
directives,
theme: {
defaultTheme: 'dark',
themes: {
dark: { colors: { primary: '#00cec9', secondary: '#ffeaa7' } }
}
},
icons: { defaultSet: 'mdi', aliases, sets: { mdi } }
})
app.use(pinia)
app.use(router)
app.use(vuetify)
app.use(i18n)
app.mount('#app')

306
client/src/plugins/i18n.js Normal file
View File

@@ -0,0 +1,306 @@
import { createI18n } from 'vue-i18n'
const messages = {
en: {
common: {
close: "Close",
cancel: "Cancel"
},
nav: {
dashboard: "Dashboard",
review: "Review",
collection: "Collection",
settings: "Settings",
sync: "Sync",
logout: "Logout",
menu: "Menu"
},
login: {
instruction: "Enter your WaniKani V2 API Key to login",
placeholder: "Paste key here...",
button: "Login",
failed: "Login failed. Is server running?"
},
alerts: {
syncSuccess: "Sync complete! Collection: {count}",
syncFailed: "Sync failed.",
logoutConfirm: "Are you sure you want to log out? This will end your session.",
},
hero: {
welcome: "Welcome Back",
subtitle: "Your mind is ready.",
start: "Start Review",
noReviews: "No Reviews",
nextIn: "Next review in",
now: "now",
prioritize: "Prioritize Lower Levels ({count})"
},
stats: {
mastery: "Mastery (Guru+)",
srsDistribution: "SRS Levels",
accuracy: "Global Accuracy",
correct: "Correct",
total: "Total",
next24: "Next 24h",
availableNow: "Available Now",
inHours: "In {n} hour | In {n} hours",
noIncoming: "No reviews incoming for 24 hours.",
items: "items",
reviewsCount: "{count} reviews",
consistency: "Study Consistency",
less: "Less",
more: "More",
streakTitle: "Study Streak",
days: "days",
shieldActive: "Zen Shield Active: Protects streak if you miss 1 day.",
shieldCooldown: "Regenerating: {n} days left",
ghostTitle: "Ghost Items",
ghostSubtitle: "Lowest Accuracy",
noGhosts: "No ghosts found! Keep it up."
},
settings: {
title: "Settings",
batchSize: "Review Batch Size",
items: "Items",
language: "Language",
save: "Save & Close"
},
review: {
meaning: "Meaning",
level: "Level",
draw: "Draw correctly",
hint: "Hint Shown",
tryAgain: "Try again",
correct: "Correct!",
next: "NEXT",
sessionComplete: "Session Complete!",
levelup: "You leveled up your Kanji skills.",
back: "Back to Collection",
caughtUp: "All Caught Up!",
noReviews: "No reviews available right now.",
viewCollection: "View Collection",
queue: "Session queue:",
loading: "Loading Kanji...",
},
collection: {
searchLabel: "Search Kanji, Meaning, or Reading...",
placeholder: "e.g. 'water', 'mizu', '水'",
loading: "Loading Collection...",
noMatches: "No matches found",
tryDifferent: "Try searching for a different meaning or reading.",
levelHeader: "LEVEL",
onyomi: "On'yomi",
kunyomi: "Kun'yomi",
nanori: "Nanori",
close: "Close"
}
},
de: {
common: {
close: "Schließen",
cancel: "Abbrechen"
},
nav: {
dashboard: "Übersicht",
review: "Lernen",
collection: "Sammlung",
settings: "Einstellungen",
sync: "Sync",
logout: "Abmelden",
menu: "Menü"
},
login: {
instruction: "Gib deinen WaniKani V2 API Key ein",
placeholder: "Key hier einfügen...",
button: "Anmelden",
failed: "Login fehlgeschlagen. Läuft der Server?"
},
alerts: {
syncSuccess: "Sync fertig! Sammlung: {count}",
syncFailed: "Sync fehlgeschlagen.",
logoutConfirm: "Möchtest du dich wirklich abmelden? Deine Sitzung wird beendet.",
},
hero: {
welcome: "Willkommen zurück",
subtitle: "Dein Geist ist bereit.",
start: "Starten",
noReviews: "Alles erledigt",
nextIn: "Nächste Review in",
now: "jetzt",
prioritize: "Niedrige Stufen zuerst ({count})"
},
stats: {
mastery: "Meisterschaft (Guru+)",
srsDistribution: "SRS Verteilung",
accuracy: "Genauigkeit",
correct: "Richtig",
total: "Gesamt",
next24: "Nächste 24h",
availableNow: "Jetzt verfügbar",
inHours: "In {n} Stunde | In {n} Stunden",
noIncoming: "Keine Reviews in den nächsten 24h.",
items: "Einträge",
reviewsCount: "{count} Reviews",
consistency: "Lern-Konstanz",
less: "Weniger",
more: "Mehr",
streakTitle: "Lern-Serie",
days: "Tage",
shieldActive: "Zen-Schild Aktiv: Schützt dich bei einem verpassten Tag.",
shieldCooldown: "Regeneriert: noch {n} Tage",
ghostTitle: "Geister-Items",
ghostSubtitle: "Niedrigste Genauigkeit",
noGhosts: "Keine Geister gefunden! Weiter so."
},
settings: {
title: "Einstellungen",
batchSize: "Anzahl pro Sitzung",
items: "Einträge",
language: "Sprache",
save: "Speichern & Schließen"
},
review: {
meaning: "Bedeutung",
level: "Stufe",
draw: "Zeichne das Kanji",
hint: "Hinweis angezeigt",
tryAgain: "Nochmal versuchen",
correct: "Richtig!",
next: "WEITER",
sessionComplete: "Sitzung beendet!",
levelup: "Du hast deine Kanji-Skills verbessert.",
back: "Zurück zur Sammlung",
caughtUp: "Alles erledigt!",
noReviews: "Gerade keine Reviews verfügbar.",
viewCollection: "Zur Sammlung",
queue: "Verbleibend:",
loading: "Lade Kanji...",
},
collection: {
searchLabel: "Suche Kanji, Bedeutung oder Lesung...",
placeholder: "z.B. 'Wasser', 'mizu'",
loading: "Lade Sammlung...",
noMatches: "Keine Treffer",
tryDifferent: "Versuche einen anderen Suchbegriff.",
levelHeader: "STUFE",
onyomi: "On'yomi",
kunyomi: "Kun'yomi",
nanori: "Nanori",
close: "Schließen"
}
},
ja: {
common: {
close: "閉じる",
cancel: "キャンセル"
},
nav: {
dashboard: "ダッシュボード",
review: "復習",
collection: "コレクション",
settings: "設定",
sync: "同期",
logout: "ログアウト",
menu: "メニュー"
},
login: {
instruction: "WaniKani V2 APIキーを入力してください",
placeholder: "キーを貼り付け...",
button: "ログイン",
failed: "ログイン失敗。サーバーは起動していますか?"
},
alerts: {
syncSuccess: "同期完了! コレクション: {count}",
syncFailed: "同期に失敗しました。",
logoutConfirm: "ログアウトしてもよろしいですか?セッションが終了します。",
},
hero: {
welcome: "お帰りなさい",
subtitle: "準備は完了です。",
start: "復習開始",
noReviews: "レビューなし",
nextIn: "次の復習まで",
now: "今",
prioritize: "低レベルを優先 ({count})"
},
stats: {
mastery: "習得度 (Guru+)",
srsDistribution: "SRS分布",
accuracy: "正解率",
correct: "正解",
total: "合計",
next24: "今後24時間",
availableNow: "今すぐ可能",
inHours: "{n}時間後",
noIncoming: "24時間以内のレビューはありません。",
items: "個",
reviewsCount: "{count} レビュー",
consistency: "学習の一貫性",
less: "少",
more: "多",
streakTitle: "連続学習日数",
days: "日",
shieldActive: "Zenシールド有効: 1日休んでもストリークを守ります。",
shieldCooldown: "再チャージ中: 残り{n}日",
ghostTitle: "苦手なアイテム",
ghostSubtitle: "正解率が低い",
noGhosts: "苦手なアイテムはありません!"
},
settings: {
title: "設定",
batchSize: "1回の復習数",
items: "個",
language: "言語 (Language)",
save: "保存して閉じる"
},
review: {
meaning: "意味",
level: "レベル",
draw: "正しく描いてください",
hint: "ヒント表示",
tryAgain: "もう一度",
correct: "正解!",
next: "次へ",
sessionComplete: "セッション完了!",
levelup: "漢字力がアップしました。",
back: "コレクションに戻る",
caughtUp: "完了しました!",
noReviews: "現在レビューするものはありません。",
viewCollection: "コレクションを見る",
queue: "残り:",
loading: "漢字を読み込み中...",
},
collection: {
searchLabel: "漢字、意味、読みで検索...",
placeholder: "例: '水', 'mizu'",
loading: "読み込み中...",
noMatches: "見つかりませんでした",
tryDifferent: "別のキーワードで検索してください。",
levelHeader: "レベル",
onyomi: "音読み",
kunyomi: "訓読み",
nanori: "名乗り",
close: "閉じる"
}
}
}
const savedLocale = localStorage.getItem('zen_locale') || 'en'
const i18n = createI18n({
legacy: false,
locale: savedLocale,
fallbackLocale: 'en',
messages
})
export default i18n

View File

@@ -0,0 +1,136 @@
import { defineStore } from 'pinia';
const BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
export const useAppStore = defineStore('app', {
state: () => ({
token: localStorage.getItem('zen_token') || '',
user: null,
queue: [],
collection: [],
stats: {
distribution: {},
forecast: [],
queueLength: 0,
streak: {},
accuracy: {},
ghosts: []
},
batchSize: 20,
drawingAccuracy: 10,
loading: false
}),
actions: {
async login(apiKey) {
const res = await fetch(`${BASE_URL}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ apiKey })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Login failed');
this.token = data.token;
this.user = data.user;
if (data.user.settings) {
this.batchSize = data.user.settings.batchSize || 20;
this.drawingAccuracy = data.user.settings.drawingAccuracy || 10;
}
localStorage.setItem('zen_token', data.token);
await this.fetchStats();
return data;
},
async logout() {
try {
if (this.token) {
await fetch(`${BASE_URL}/api/auth/logout`, {
method: 'POST',
headers: this.getHeaders()
});
}
} catch (e) {
console.error("Logout error:", e);
} finally {
this.clearData();
}
},
clearData() {
this.token = '';
this.user = null;
this.queue = [];
this.stats = {};
localStorage.removeItem('zen_token');
},
getHeaders() {
return {
'Authorization': `Bearer ${this.token}`,
'Content-Type': 'application/json'
};
},
async sync() {
const res = await fetch(`${BASE_URL}/api/sync`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify({})
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
return data;
},
async fetchStats() {
if (!this.token) return;
const res = await fetch(`${BASE_URL}/api/stats`, { headers: this.getHeaders() });
if (res.status === 401) return this.logout();
const data = await res.json();
this.stats = data;
return data;
},
async fetchQueue(sortMode = 'shuffled') {
if (!this.token) return;
const res = await fetch(`${BASE_URL}/api/queue?limit=${this.batchSize}&sort=${sortMode}`, {
headers: this.getHeaders()
});
if (res.status === 401) return this.logout();
this.queue = await res.json();
},
async fetchCollection() {
if (!this.token) return;
const res = await fetch(`${BASE_URL}/api/collection`, { headers: this.getHeaders() });
if (res.status === 401) return this.logout();
this.collection = await res.json();
},
async submitReview(subjectId, success) {
const res = await fetch(`${BASE_URL}/api/review`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify({ subjectId, success })
});
if (res.status === 401) return this.logout();
return await res.json();
},
async saveSettings(settings) {
if (settings.batchSize) this.batchSize = settings.batchSize;
if (settings.drawingAccuracy) this.drawingAccuracy = settings.drawingAccuracy;
await fetch(`${BASE_URL}/api/settings`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(settings)
});
}
}
});

View File

@@ -0,0 +1,68 @@
@use 'sass:color';
@use 'variables' as *;
@mixin flex-center {
display: flex;
align-items: center;
justify-content: center;
}
@mixin flex-column($gap: 0) {
display: flex;
flex-direction: column;
gap: $gap;
}
@mixin grid-responsive($min-width: 960px) {
display: grid;
grid-template-columns: 1fr;
@media (min-width: $min-width) {
// stylelint-disable-next-line no-invalid-position-declaration
grid-template-columns: 1fr 1fr;
}
}
@mixin card-base {
background: $color-surface-light;
border-radius: $radius-md;
border: 1px solid transparent;
background-clip: padding-box;
}
@mixin hover-lift {
transition:
transform $duration-fast $ease-default,
background $duration-fast $ease-default,
box-shadow $duration-fast $ease-default;
will-change: transform;
transform: translateZ(0);
backface-visibility: hidden;
&:hover {
transform: translateY(-2px);
background: color.adjust($color-surface-light, $lightness: 5%);
box-shadow: $shadow-md;
}
}
@mixin scrollbar {
scrollbar-width: thin;
scrollbar-color: #333 transparent;
}
@mixin animate-fade-up($duration: 0.4s) {
animation: fade-slide-up $duration ease-out backwards;
@keyframes fade-slide-up {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
}

View File

@@ -0,0 +1,5 @@
@forward 'variables/colors';
@forward 'variables/typography';
@forward 'variables/spacing';
@forward 'variables/layout';
@forward 'variables/effects';

View File

@@ -0,0 +1,18 @@
$color-primary: hsl(178deg 100% 40%);
$color-secondary: hsl(46deg 100% 82%);
$color-bg-dark: hsl(0deg 0% 7%);
$color-surface: hsl(240deg 9% 13%);
$color-surface-light: hsl(221deg 17% 22%);
$color-text-white: hsl(0deg 0% 100%);
$color-text-grey: hsl(213deg 14% 70%);
$color-border: hsl(0deg 0% 100% / 8%);
$color-srs-1: hsl(0deg 100% 73%);
$color-srs-2: hsl(39deg 98% 71%);
$color-srs-3: hsl(163deg 85% 64%);
$color-srs-4: hsl(206deg 92% 46%);
$color-srs-5: hsl(244deg 100% 82%);
$color-srs-6: hsl(247deg 72% 63%);
$color-danger: hsl(0deg 85% 65%);
$color-success: hsl(160deg 80% 45%);
$color-stroke-inactive: hsl(0deg 0% 33%);
$color-dot-base: hsl(0deg 0% 27%);

View File

@@ -0,0 +1,30 @@
@use 'sass:color';
@use 'colors' as *;
$opacity-disabled: 0.5;
$opacity-inactive: 0.3;
$opacity-hover: 0.8;
$opacity-active: 1;
$blur-sm: blur(4px);
$blur-md: blur(10px);
$blur-lg: blur(20px);
$bg-glass-subtle: hsl(0deg 0% 100% / 5%);
$bg-glass-light: hsl(0deg 0% 100% / 6%);
$bg-glass-strong: hsl(0deg 0% 100% / 10%);
$bg-glass-dark: hsl(0deg 0% 0% / 20%);
$shadow-sm: 0 2px 4px hsl(0deg 0% 0% / 20%);
$shadow-md: 0 4px 15px hsl(0deg 0% 0% / 30%);
$shadow-lg: 0 20px 50px hsl(0deg 0% 0% / 50%);
$shadow-inset: inset 0 0 20px hsl(0deg 0% 0% / 30%);
$text-shadow: 0 2px 10px hsl(0deg 0% 0% / 50%);
$shadow-glow-xs: 0 0 4px hsl(0deg 0% 100% / 50%);
$shadow-glow-base: 0 0 20px color.change($color-primary, $alpha: 0.4);
$shadow-glow-hover: 0 0 40px color.change($color-primary, $alpha: 0.6);
$shadow-glow-active: 0 0 20px color.change($color-primary, $alpha: 0.4);
$dist-slide-sm: 10px;
$duration-fast: 0.2s;
$duration-normal: 0.3s;
$duration-slow: 0.5s;
$duration-chart: 1s;
$ease-default: ease;
$ease-out-back: cubic-bezier(0.175, 0.885, 0.32, 1.275);

View File

@@ -0,0 +1,46 @@
@use 'colors' as *;
$radius-sm: 4px;
$radius-md: 8px;
$radius-lg: 12px;
$radius-xl: 24px;
$radius-pill: 999px;
$radius-circle: 50%;
$border-width-sm: 1px;
$border-width-md: 2px;
$border-subtle: $border-width-sm solid $color-border;
$border-focus: $border-width-md solid $color-primary;
$border-transparent: $border-width-md solid transparent;
$z-back: -1;
$z-normal: 1;
$z-sticky: 10;
$z-dropdown: 50;
$z-modal: 100;
$z-tooltip: 200;
$z-above: 2;
$size-canvas: 300px;
$size-kanji-preview: 200px;
$size-icon-btn: 32px;
$size-icon-small: 18px;
$stroke-width-main: 3px;
$stroke-width-arrow: 2px;
$font-size-svg-number: 5px;
$radius-xs: 2px;
$radius-sm: 4px;
$radius-md: 8px;
$radius-lg: 12px;
$radius-xl: 24px;
$radius-pill: 999px;
$radius-circle: 50%;
$size-legend-box: 12px;
$size-streak-dot: 24px;
$size-srs-track: 24px;
$size-chart-height: 160px;
$size-ghost-min-height: 80px;
$max-width-heatmap: 950px;
$max-width-desktop: 1400px;
$padding-page-y: 40px;
$padding-page-x: 24px;
$breakpoint-md: 960px;
$offset-fab: 20px;
$size-heatmap-cell-height: 10px;

View File

@@ -0,0 +1,10 @@
$spacing-2xs: 2px;
$spacing-xs: 4px;
$spacing-sm: 8px;
$spacing-md: 16px;
$spacing-lg: 24px;
$spacing-xl: 32px;
$spacing-xxl: 48px;
$size-loading-spinner: 64px;
$gap-heatmap: 3px;
$size-card-min: 60px;

View File

@@ -0,0 +1,25 @@
$font-family-sans:
'Inter',
system-ui,
-apple-system,
sans-serif;
$font-family-mono: 'Fira Code', monospace;
$font-xs: 0.75rem;
$font-sm: 0.875rem;
$font-md: 1rem;
$font-lg: 1.25rem;
$font-xl: 1.5rem;
$font-2xl: 2rem;
$font-3xl: 3rem;
$weight-regular: 400;
$weight-medium: 500;
$weight-bold: 700;
$weight-black: 900;
$tracking-tighter: -0.05em;
$tracking-tight: -0.025em;
$tracking-normal: 0em;
$tracking-wide: 0.025em;
$tracking-wider: 0.05em;
$leading-tight: 1.2;
$leading-normal: 1.5;
$leading-loose: 1.8;

View File

@@ -0,0 +1,23 @@
@use '../abstracts/variables' as *;
body {
font-family: $font-family-sans;
line-height: $leading-normal;
-webkit-font-smoothing: antialiased;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: $weight-bold;
line-height: $leading-tight;
letter-spacing: $tracking-tight;
}
code,
pre {
font-family: $font-family-mono;
}

View File

@@ -0,0 +1,26 @@
@use '../abstracts/variables' as *;
.glow-btn {
box-shadow: $shadow-glow-base;
transition:
transform $duration-fast $ease-default,
box-shadow $duration-fast $ease-default;
will-change: transform, box-shadow;
&:hover {
transform: translateY(-2px);
box-shadow: $shadow-glow-hover;
}
&:active {
transform: translateY(0);
box-shadow: $shadow-glow-active;
}
&:disabled {
box-shadow: none;
transform: none;
opacity: $opacity-disabled;
cursor: not-allowed;
}
}

View File

@@ -0,0 +1,147 @@
@use '../abstracts/variables' as *;
@use '../abstracts/mixins' as *;
.canvas-container {
position: relative;
@include flex-center;
margin-bottom: $spacing-xl;
}
.canvas-wrapper {
width: $size-canvas;
height: $size-canvas;
border-radius: $radius-lg;
background: rgba($color-surface, 0.95);
border: $border-width-md solid $color-surface-light;
position: relative;
cursor: crosshair;
box-shadow: $shadow-inset;
canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
touch-action: none;
}
}
.loading-text {
position: absolute;
color: $color-text-grey;
z-index: $z-sticky;
font-size: $font-sm;
font-weight: $weight-medium;
letter-spacing: $tracking-wide;
}
.svg-container {
width: $size-kanji-preview;
height: $size-kanji-preview;
margin: 0 auto $spacing-lg;
background: rgba($color-bg-dark, 0.2);
border-radius: $radius-md;
border: $border-subtle;
position: relative;
@include flex-center;
overflow: hidden;
}
.stroke-path {
fill: none;
stroke: $color-stroke-inactive;
stroke-width: $stroke-width-main;
stroke-linecap: round;
stroke-linejoin: round;
transition: stroke $duration-normal;
&.hidden {
opacity: 0;
}
&.animating {
stroke: $color-primary;
stroke-dasharray: var(--len);
stroke-dashoffset: var(--len);
animation: draw-stroke var(--duration) linear forwards;
}
&.drawn {
stroke: $color-text-white;
opacity: 1;
}
}
@keyframes draw-stroke {
to {
stroke-dashoffset: 0;
}
}
.stroke-start-circle {
fill: $color-srs-1;
}
.stroke-number {
fill: $color-text-white;
font-size: $font-size-svg-number;
font-family: $font-family-sans;
font-weight: $weight-bold;
text-anchor: middle;
dominant-baseline: middle;
pointer-events: none;
}
.stroke-arrow-line {
fill: none;
stroke: rgba($color-secondary, 0.7);
stroke-width: $stroke-width-arrow;
stroke-linecap: round;
stroke-linejoin: round;
}
.loading-spinner {
color: $color-text-grey;
font-size: $font-sm;
}
.play-btn {
position: absolute;
top: $spacing-sm;
right: $spacing-sm;
background: rgba($color-bg-dark, 0.3);
border: $border-width-sm solid rgba($color-primary, 0.5);
color: $color-primary;
border-radius: $radius-circle;
width: $size-icon-btn;
height: $size-icon-btn;
@include flex-center;
cursor: pointer;
transition: all $duration-fast ease;
z-index: $z-sticky;
backdrop-filter: $blur-sm;
&:hover {
transform: scale(1.1);
background: $color-primary;
color: $color-bg-dark;
border-color: $color-primary;
box-shadow: $shadow-glow-base;
}
&:active {
transform: scale(0.95);
}
svg {
width: $size-icon-small;
height: $size-icon-small;
}
}

View File

@@ -0,0 +1,174 @@
@use '../abstracts/variables' as *;
@use '../abstracts/mixins' as *;
@use 'sass:color';
.widget-card {
@include card-base;
border: $border-subtle;
}
.level-0 {
background-color: $bg-glass-subtle;
}
.level-1 {
background-color: color.mix($color-primary, $color-surface, 25%);
}
.level-2 {
background-color: color.mix($color-primary, $color-surface, 50%);
}
.level-3 {
background-color: $color-primary;
}
.level-4 {
background-color: color.scale($color-primary, $lightness: 40%);
}
.heatmap-container {
width: 100%;
@include flex-center;
}
.heatmap-year {
display: grid;
grid-template-columns: repeat(53, 1fr);
gap: $gap-heatmap;
width: 100%;
}
.heatmap-week {
display: grid;
grid-template-rows: repeat(7, max-content);
gap: $gap-heatmap;
}
.heatmap-day-wrapper {
width: 100%;
}
.heatmap-cell {
width: 100%;
height: $size-heatmap-cell-height;
border-radius: $radius-xs;
transition:
opacity $duration-fast,
background-color $duration-fast;
&:hover {
opacity: $opacity-hover;
}
}
.today-cell {
border: $border-width-sm solid $color-text-white;
box-shadow: $shadow-glow-xs;
z-index: $z-above;
position: relative;
box-sizing: border-box;
}
.legend-container {
display: flex;
align-items: center;
gap: $spacing-xs;
}
.legend-box {
width: $size-legend-box;
height: $size-legend-box;
border-radius: $radius-xs;
}
.ghost-card {
background: $bg-glass-subtle;
border-radius: $radius-md;
padding: $spacing-sm;
text-align: center;
border: $border-width-sm solid $bg-glass-subtle;
display: flex;
flex-direction: column;
justify-content: center;
min-height: $size-ghost-min-height;
}
.srs-chart-container {
height: $size-chart-height;
width: 100%;
}
.srs-column {
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.srs-track {
width: $size-srs-track;
flex-grow: 1;
background: $bg-glass-light;
border-radius: $radius-md;
position: relative;
overflow: hidden;
display: flex;
align-items: flex-end;
margin: $spacing-xs 0;
}
.srs-fill {
width: 100%;
border-radius: $radius-sm;
transition: height 1s $ease-out-back;
min-height: 0;
}
.transition-text {
transition: color $duration-normal $ease-default;
}
.streak-dot {
width: $size-streak-dot;
height: $size-streak-dot;
border-radius: $radius-circle;
background: $bg-glass-strong;
@include flex-center;
transition: all $duration-normal;
&.active {
background: $color-primary;
box-shadow: $shadow-glow-active;
}
}
.forecast-list {
overflow-y: auto;
@include scrollbar;
}
.gap-1 {
gap: $spacing-xs;
}
.gap-2 {
gap: $spacing-sm;
}
.border-subtle {
border: $border-subtle;
}
.border-b-subtle {
border-bottom: $border-subtle;
}
.border-t-subtle {
border-top: $border-subtle;
}

View File

@@ -0,0 +1,10 @@
@use 'abstracts/variables';
@use 'abstracts/mixins';
@use 'base/typography';
@use 'components/buttons';
@use 'components/widgets';
@use 'components/kanji';
@use 'pages/app';
@use 'pages/dashboard';
@use 'pages/review';
@use 'pages/collection';

View File

@@ -0,0 +1,61 @@
@use '../abstracts/variables' as *;
:root {
--v-theme-background: #{$color-bg-dark};
}
body,
html,
.zen-app,
.v-application {
background-color: $color-bg-dark !important;
font-family: $font-family-sans;
color: $color-text-white;
}
.v-navigation-drawer {
background-color: $color-surface !important;
// stylelint-disable-next-line selector-class-pattern
.v-list-item--active {
background-color: rgba($color-primary, 0.15);
color: $color-primary !important;
.v-icon {
color: $color-primary !important;
}
}
}
.v-card {
box-shadow: $shadow-lg !important;
}
.app-bar-blur {
border-bottom-color: $color-border !important;
backdrop-filter: $blur-md;
}
.cursor-pointer {
cursor: pointer;
}
.tracking-wide {
letter-spacing: $tracking-wider;
}
.tracking-tight {
letter-spacing: $tracking-tight;
}
.border-subtle {
border: $border-subtle;
}
.logo-hover {
transition: opacity $duration-fast $ease-default;
&:hover {
opacity: $opacity-hover;
}
}

View File

@@ -0,0 +1,128 @@
@use '../abstracts/variables' as *;
@use '../abstracts/mixins' as *;
.sticky-search {
position: sticky;
top: $spacing-sm;
z-index: $z-sticky;
box-shadow: $shadow-md;
border-radius: $radius-sm;
}
.fade-slide-up {
@include animate-fade-up;
}
.kanji-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax($size-card-min, 1fr));
gap: $spacing-sm;
}
.kanji-card {
@include card-base;
@include hover-lift;
aspect-ratio: 1;
@include flex-column;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
.k-char {
font-size: $font-2xl;
font-weight: $weight-medium;
line-height: $leading-tight;
transition: all 0.3s ease;
}
&.zen-master {
border: 1px solid rgb(255 215 0 / 50%);
background: linear-gradient(135deg, rgb(30 30 36 / 100%) 0%, rgb(45 45 55 / 100%) 100%);
box-shadow: 0 0 15px rgb(255 215 0 / 15%);
animation: zen-pulse 4s infinite ease-in-out;
.k-char {
text-shadow: 0 0 15px rgb(255 215 0 / 60%);
transform: scale(1.1);
}
}
.k-dots {
display: flex;
gap: $spacing-2xs;
margin-top: $spacing-xs;
.dot {
width: $spacing-xs;
height: $spacing-xs;
border-radius: $radius-circle;
background: $color-dot-base;
}
}
.k-bars {
display: flex;
gap: 3px;
width: 60%;
height: 4px;
margin-top: $spacing-sm;
.bar {
flex: 1;
background: rgb(255 255 255 / 10%);
border-radius: 2px;
transition: background-color 0.3s ease;
&.filled {
box-shadow: 0 0 4px rgb(0 0 0 / 30%);
}
}
}
}
@keyframes zen-pulse {
0% {
box-shadow: 0 0 10px rgb(255 215 0 / 10%);
border-color: rgb(255 215 0 / 30%);
}
50% {
box-shadow: 0 0 20px rgb(255 215 0 / 30%);
border-color: rgb(255 215 0 / 80%);
}
100% {
box-shadow: 0 0 10px rgb(255 215 0 / 10%);
border-color: rgb(255 215 0 / 30%);
}
}
.readings-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $spacing-sm;
text-align: left;
.reading-group {
background: $bg-glass-dark;
padding: $spacing-sm $spacing-md;
border-radius: $radius-md;
.reading-label {
font-size: $font-xs;
color: $color-text-grey;
text-transform: uppercase;
letter-spacing: $tracking-wider;
margin-bottom: $spacing-2xs;
}
.reading-value {
color: $color-text-white;
font-size: $font-sm;
}
}
}

View File

@@ -0,0 +1,34 @@
@use '../abstracts/variables' as *;
@use '../abstracts/mixins' as *;
.dashboard-layout {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-auto-rows: min-content;
gap: $spacing-lg;
width: 100%;
max-width: $max-width-desktop;
margin: 0 auto;
padding: $padding-page-y $padding-page-x;
box-sizing: border-box;
&.fade-in {
@include animate-fade-up($duration-slow);
}
.grid-area-full {
grid-column: 1 / -1;
}
.stats-split-layout {
grid-column: 1 / -1;
gap: $spacing-lg;
@include grid-responsive($breakpoint-md);
.stats-left,
.stats-right {
@include flex-column($spacing-lg);
}
}
}

View File

@@ -0,0 +1,47 @@
@use '../abstracts/variables' as *;
.text-shadow {
text-shadow: $text-shadow;
}
.canvas-wrapper {
position: relative;
width: $size-canvas;
height: $size-canvas;
border-radius: $radius-lg;
background: $bg-glass-dark;
box-shadow: $shadow-inset;
.next-fab {
position: absolute;
bottom: -#{$offset-fab};
right: -#{$offset-fab};
z-index: $z-sticky;
}
}
.scale-enter-active,
.scale-leave-active {
transition: all $duration-normal $ease-out-back;
}
.scale-enter-from,
.scale-leave-to {
opacity: 0;
transform: scale(0);
}
.fade-slide-enter-active,
.fade-slide-leave-active {
transition: all $duration-normal $ease-default;
}
.fade-slide-enter-from {
opacity: 0;
transform: translateY($dist-slide-sm);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateY(-#{$dist-slide-sm});
}

View File

@@ -0,0 +1,181 @@
<template lang="pug">
v-container
v-text-field.mb-6.sticky-search(
v-model="searchQuery"
prepend-inner-icon="mdi-magnify"
:label="$t('collection.searchLabel')"
:placeholder="$t('collection.placeholder')"
variant="solo-filled"
density="comfortable"
bg-color="#2f3542"
color="white"
hide-details
clearable
)
.text-center.mt-10(v-if="loading") {{ $t('collection.loading') }}
.text-center.mt-10.text-grey-lighten-1(
v-else-if="!loading && Object.keys(groupedItems).length === 0"
)
v-icon.mb-4(size="64" color="grey-darken-2") mdi-text-search-variant
.text-h6 {{ $t('collection.noMatches') }}
.text-body-2 {{ $t('collection.tryDifferent') }}
.mb-6.fade-slide-up(
v-else
v-for="(group, level) in groupedItems"
:key="level"
)
.text-subtitle-2.text-grey.mb-2.font-weight-bold.ml-1
| {{ $t('collection.levelHeader') }} {{ level }}
.kanji-grid
.kanji-card(
v-for="item in group"
:key="item._id"
:class="{ 'zen-master': item.srsLevel >= 10 }"
@click="openDetail(item)"
)
.k-char(:style="{ color: item.srsLevel >= 10 ? '#ffd700' : getSRSColor(item.srsLevel) }")
| {{ item.char }}
.k-dots(v-if="item.srsLevel <= 6")
.dot(
v-for="n in 5"
:key="n"
:class="{ filled: n < item.srsLevel }"
:style="{ background: n < item.srsLevel ? getSRSColor(item.srsLevel) : '' }"
)
.k-bars(v-else-if="item.srsLevel < 10")
.bar(
:class="{ filled: item.srsLevel >= 7 }"
:style="{ background: item.srsLevel >= 7 ? getSRSColor(item.srsLevel) : '' }"
)
.bar(
:class="{ filled: item.srsLevel >= 8 }"
:style="{ background: item.srsLevel >= 8 ? getSRSColor(item.srsLevel) : '' }"
)
.bar(
:class="{ filled: item.srsLevel >= 9 }"
:style="{ background: item.srsLevel >= 9 ? getSRSColor(item.srsLevel) : '' }"
)
v-dialog(
v-model="showModal"
max-width="400"
transition="dialog-bottom-transition"
)
v-card.pa-4.pt-6.text-center.rounded-xl.border-subtle(color="#1e1e24")
.d-flex.justify-space-between.align-center.px-2.mb-2
.text-caption.text-grey {{ $t('review.level') }} {{ selectedItem?.level }}
v-chip.font-weight-bold(
size="x-small"
:color="getSRSColor(selectedItem?.srsLevel)"
variant="flat"
) {{ getNextReviewText(selectedItem?.nextReview, selectedItem?.srsLevel) }}
.text-h2.font-weight-bold.mb-1 {{ selectedItem?.char }}
.text-subtitle-1.text-grey-lighten-1.text-capitalize.mb-4 {{ selectedItem?.meaning }}
KanjiSvgViewer(
v-if="showModal && selectedItem"
:char="selectedItem.char"
)
.readings-container.mb-4
.reading-group(v-if="hasReading(selectedItem?.onyomi)")
.reading-label {{ $t('collection.onyomi') }}
.reading-value {{ selectedItem?.onyomi.join(', ') }}
.reading-group(v-if="hasReading(selectedItem?.kunyomi)")
.reading-label {{ $t('collection.kunyomi') }}
.reading-value {{ selectedItem?.kunyomi.join(', ') }}
.reading-group(v-if="hasReading(selectedItem?.nanori)")
.reading-label {{ $t('collection.nanori') }}
.reading-value {{ selectedItem?.nanori.join(', ') }}
v-btn.text-white(
block
color="#2f3542"
@click="showModal = false"
) {{ $t('collection.close') }}
</template>
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useAppStore } from '@/stores/appStore';
import { useI18n } from 'vue-i18n';
import KanjiSvgViewer from '@/components/kanji/KanjiSvgViewer.vue';
const { t } = useI18n();
const store = useAppStore();
const loading = ref(true);
const showModal = ref(false);
const selectedItem = ref(null);
const searchQuery = ref('');
onMounted(async () => {
await store.fetchCollection();
loading.value = false;
});
const filteredCollection = computed(() => {
if (!searchQuery.value) return store.collection;
const q = searchQuery.value.toLowerCase().trim();
return store.collection.filter(item => {
if (item.meaning && item.meaning.toLowerCase().includes(q)) return true;
if (item.char && item.char.includes(q)) return true;
if (item.onyomi && item.onyomi.some(r => r.includes(q))) return true;
if (item.kunyomi && item.kunyomi.some(r => r.includes(q))) return true;
if (item.nanori && item.nanori.some(r => r.includes(q))) return true;
return false;
});
});
const groupedItems = computed(() => {
const groups = {};
filteredCollection.value.forEach(i => {
if (!groups[i.level]) groups[i.level] = [];
groups[i.level].push(i);
});
return groups;
});
const SRS_COLORS = {
1: '#ff7675', 2: '#fdcb6e', 3: '#55efc4',
4: '#0984e3', 5: '#a29bfe', 6: '#6c5ce7',
7: '#00cec9', 8: '#fd79a8', 9: '#e84393',
10: '#ffd700'
};
const getSRSColor = (lvl) => SRS_COLORS[lvl] || '#444';
const openDetail = (item) => {
selectedItem.value = item;
showModal.value = true;
};
const hasReading = (arr) => arr && arr.length > 0;
const getNextReviewText = (dateStr, srsLvl) => {
if (srsLvl >= 10) return 'COMPLETE';
const date = new Date(dateStr);
const now = new Date();
if (date <= now) return t('stats.availableNow');
const diffMs = date - now;
const diffHrs = Math.floor(diffMs / 3600000);
if (diffHrs < 24) return t('stats.inHours', { n: diffHrs }, diffHrs);
const diffDays = Math.floor(diffHrs / 24);
return `Next: ${diffDays}d`;
};
</script>
<style lang="scss" src="@/styles/pages/_collection.scss" scoped></style>

View File

@@ -0,0 +1,81 @@
<template lang="pug">
.dashboard-layout.fade-in
WidgetWelcome(
:queue-length="stats.queueLength"
:has-lower-levels="stats.hasLowerLevels"
:lower-level-count="stats.lowerLevelCount"
:forecast="stats.forecast"
@start="handleStart"
)
.grid-area-full(v-if="!loading")
WidgetHeatmap(:heatmap-data="stats.heatmap")
.stats-split-layout(v-if="!loading")
.stats-left.d-flex.flex-column.gap-4
WidgetGhosts(:ghosts="stats.ghosts")
WidgetDistribution(:distribution="stats.distribution")
WidgetGuruMastery(:distribution="stats.distribution")
.stats-right
WidgetAccuracy(:accuracy="stats.accuracy")
WidgetStreak(:streak="stats.streak")
WidgetForecast(:forecast="stats.forecast")
.grid-area-full.d-flex.justify-center.mt-12(v-if="loading")
v-progress-circular(
indeterminate
color="#00cec9"
size="64"
)
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useAppStore } from '@/stores/appStore';
import { useRouter } from 'vue-router';
import WidgetWelcome from '@/components/dashboard/WidgetWelcome.vue';
import WidgetHeatmap from '@/components/dashboard/WidgetHeatmap.vue';
import WidgetGhosts from '@/components/dashboard/WidgetGhosts.vue';
import WidgetDistribution from '@/components/dashboard/WidgetDistribution.vue';
import WidgetAccuracy from '@/components/dashboard/WidgetAccuracy.vue';
import WidgetStreak from '@/components/dashboard/WidgetStreak.vue';
import WidgetForecast from '@/components/dashboard/WidgetForecast.vue';
import WidgetGuruMastery from '@/components/dashboard/WidgetGuruMastery.vue';
const store = useAppStore();
const router = useRouter();
const loading = ref(true);
const stats = ref({
queueLength: 0,
hasLowerLevels: false,
lowerLevelCount: 0,
distribution: {},
forecast: [],
ghosts: [],
accuracy: { total: 0, correct: 0 },
heatmap: {},
streak: { current: 0, history: [], shield: { ready: false } }
});
onMounted(async () => {
try {
const data = await store.fetchStats();
if (data) {
stats.value = { ...stats.value, ...data };
}
} catch (e) {
console.error("Dashboard Load Error:", e);
} finally {
loading.value = false;
}
});
function handleStart(mode) {
router.push({ path: '/review', query: { mode: mode } });
}
</script>
<style lang="scss" src="@/styles/pages/_dashboard.scss" scoped></style>

238
client/src/views/Review.vue Normal file
View File

@@ -0,0 +1,238 @@
<template lang="pug">
v-container.fill-height.justify-center.pa-4
v-fade-transition(mode="out-in")
v-card.pa-6.rounded-xl.elevation-10.border-subtle.d-flex.flex-column.align-center(
v-if="currentItem"
color="#1e1e24"
width="100%"
max-width="420"
)
.d-flex.align-center.w-100.mb-6
v-btn.mr-2(
icon="mdi-arrow-left"
variant="text"
color="grey"
to="/"
density="comfortable"
)
.text-caption.text-grey.font-weight-bold
| {{ sessionDone }} / {{ sessionTotal }}
v-spacer
v-chip.font-weight-bold(
size="small"
:color="getSRSColor(currentItem.srsLevel)"
variant="flat"
) {{ $t('review.level') }} {{ currentItem.srsLevel }}
.text-center.mb-6
.text-caption.text-grey.text-uppercase.tracking-wide.mb-1
| {{ $t('review.meaning') }}
.text-h3.font-weight-bold.text-white.text-shadow
| {{ currentItem.meaning }}
.canvas-wrapper.mb-2
KanjiCanvas(
ref="kanjiCanvasRef"
:char="currentItem.char"
@complete="handleComplete"
@mistake="handleMistake"
)
transition(name="scale")
v-btn.next-fab.glow-btn.text-black(
v-if="showNext"
@click="next"
color="#00cec9"
icon="mdi-arrow-right"
size="large"
elevation="8"
)
.mb-4
v-btn.text-caption.font-weight-bold.opacity-80(
variant="text"
color="amber-lighten-1"
prepend-icon="mdi-lightbulb-outline"
size="small"
:disabled="showNext || statusCode === 'hint'"
@click="triggerHint"
) Show Hint
v-sheet.d-flex.align-center.justify-center(
width="100%"
height="48"
color="transparent"
)
transition(name="fade-slide" mode="out-in")
.status-text.text-h6.font-weight-bold(
:key="statusCode"
:class="getStatusClass(statusCode)"
) {{ $t('review.' + statusCode) }}
v-progress-linear.mt-4(
v-model="progressPercent"
color="#00cec9"
height="4"
rounded
style="opacity: 0.3;"
)
v-card.pa-8.rounded-xl.elevation-10.border-subtle.text-center(
v-else-if="sessionTotal > 0 && store.queue.length === 0"
color="#1e1e24"
width="100%"
max-width="400"
)
.mb-6
v-avatar(color="rgba(0, 206, 201, 0.1)" size="80")
v-icon(size="40" color="#00cec9") mdi-trophy
.text-h4.font-weight-bold.mb-2 {{ $t('review.sessionComplete') }}
.text-body-1.text-grey.mb-8 {{ $t('review.levelup') }}
v-row.mb-6
v-col.border-r.border-grey-darken-3(cols="6")
.text-h3.font-weight-bold.text-white {{ accuracy }}%
.text-caption.text-grey.text-uppercase.mt-1 {{ $t('stats.accuracy') }}
v-col(cols="6")
.text-h3.font-weight-bold.text-white {{ sessionCorrect }}
.text-caption.text-grey.text-uppercase.mt-1 {{ $t('stats.correct') }}
v-btn.text-black.font-weight-bold.glow-btn(
to="/dashboard"
block
color="#00cec9"
height="50"
rounded="lg"
) {{ $t('review.back') }}
.text-center(v-else)
v-icon.mb-4(size="64" color="grey-darken-2") mdi-check-circle-outline
.text-h4.mb-2.text-white {{ $t('review.caughtUp') }}
.text-grey.mb-6 {{ $t('review.noReviews') }}
v-btn.font-weight-bold(
to="/dashboard"
color="#00cec9"
variant="tonal"
) {{ $t('review.viewCollection') }}
</template>
<script setup>
import { ref, onMounted, computed, watch } from 'vue';
import { useAppStore } from '@/stores/appStore';
import { useRoute } from 'vue-router';
import KanjiCanvas from '@/components/kanji/KanjiCanvas.vue';
const store = useAppStore();
const route = useRoute();
const currentItem = ref(null);
const statusCode = ref('draw');
const showNext = ref(false);
const isFailure = ref(false);
const kanjiCanvasRef = ref(null);
const sessionTotal = ref(0);
const sessionDone = ref(0);
const sessionCorrect = ref(0);
const accuracy = computed(() => {
if (sessionDone.value === 0) return 100;
return Math.round((sessionCorrect.value / sessionDone.value) * 100);
});
const progressPercent = computed(() => {
if (sessionTotal.value === 0) return 0;
return (sessionDone.value / sessionTotal.value) * 100;
});
onMounted(async () => {
const mode = route.query.mode || 'shuffled';
await store.fetchQueue(mode);
if (sessionTotal.value === 0 && store.queue.length > 0) {
sessionTotal.value = store.queue.length;
}
loadNext();
});
watch(() => store.batchSize, async () => {
resetSession();
});
async function resetSession() {
sessionDone.value = 0;
sessionCorrect.value = 0;
sessionTotal.value = 0;
currentItem.value = null;
const mode = route.query.mode || 'shuffled';
await store.fetchQueue(mode);
if (store.queue.length > 0) sessionTotal.value = store.queue.length;
loadNext();
}
function loadNext() {
if (store.queue.length === 0) {
currentItem.value = null;
return;
}
const idx = Math.floor(Math.random() * store.queue.length);
currentItem.value = store.queue[idx];
statusCode.value = "draw";
showNext.value = false;
isFailure.value = false;
}
function triggerHint() {
if (!kanjiCanvasRef.value) return;
isFailure.value = true;
statusCode.value = "hint";
kanjiCanvasRef.value.drawGuide(true);
}
function handleMistake(isHint) {
if (isHint) {
isFailure.value = true;
statusCode.value = "hint";
} else {
statusCode.value = "tryAgain";
}
}
function handleComplete() {
statusCode.value = "correct";
showNext.value = true;
}
async function next() {
if (!currentItem.value) return;
await store.submitReview(currentItem.value.wkSubjectId, !isFailure.value);
sessionDone.value++;
if (!isFailure.value) sessionCorrect.value++;
const index = store.queue.findIndex(i => i._id === currentItem.value._id);
if (index !== -1) {
store.queue.splice(index, 1);
}
loadNext();
}
const getSRSColor = (lvl) => {
const colors = { 1: '#ff7675', 2: '#fdcb6e', 3: '#55efc4', 4: '#0984e3', 5: '#a29bfe', 6: '#6c5ce7' };
return colors[lvl] || 'grey';
};
const getStatusClass = (status) => {
switch (status) {
case 'hint': return 'text-red-lighten-1';
case 'correct': return 'text-teal-accent-3';
default: return 'text-grey-lighten-1';
}
};
</script>
<style lang="scss" src="@/styles/pages/_review.scss" scoped></style>

View File

@@ -0,0 +1,17 @@
/** @type {import('stylelint').Config} */
export default {
extends: [
'stylelint-config-standard-scss',
'stylelint-config-recommended-vue'
],
ignoreFiles: [
'**/node_modules/**',
'**/dist/**',
'**/android/**'
],
rules: {
'at-rule-no-unknown': null,
'scss/at-rule-no-unknown': true,
'no-empty-source': null,
}
};

19
client/vite.config.js Normal file
View File

@@ -0,0 +1,19 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
css: {
preprocessorOptions: {
scss: {
additionalData: `@use "@/styles/abstracts/_variables.scss" as *; @use "@/styles/abstracts/_mixins.scss" as *;`
}
}
}
})

51
docker-compose.yml Normal file
View File

@@ -0,0 +1,51 @@
services:
mongo:
image: mongo:6
container_name: zen_mongo
restart: always
ports:
- "27017:27017"
volumes:
- mongo-data:/data/db
networks:
- zen-network
server:
build: ./server
container_name: zen_server
restart: always
ports:
- "3000:3000"
env_file:
- .env
depends_on:
- mongo
networks:
- zen-network
volumes:
- ./server:/app
- /app/node_modules
client:
build:
context: ./client
target: dev-stage
container_name: zen_client
ports:
- "5173:5173"
env_file:
- .env
depends_on:
- server
networks:
- zen-network
volumes:
- ./client:/app
- /app/node_modules
volumes:
mongo-data:
networks:
zen-network:
driver: bridge

13
server/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM node:24-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "start"]

2851
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
server/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "zen-kanji-server",
"version": "1.0.0",
"type": "module",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js",
"test": "vitest",
"test:cov": "vitest run --coverage"
},
"dependencies": {
"@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.0.0",
"cors": "^2.8.5",
"fastify": "^5.6.2",
"fastify-cors": "^6.0.3",
"mongoose": "^9.0.1",
"node-fetch": "^3.3.2"
},
"devDependencies": {
"@vitest/coverage-v8": "^4.0.16",
"vitest": "^4.0.16"
}
}

73
server/server.js Normal file
View File

@@ -0,0 +1,73 @@
import Fastify from 'fastify';
import cors from '@fastify/cors';
import jwt from '@fastify/jwt';
import { PORT, JWT_SECRET } from './src/config/constants.js';
import { connectDB } from './src/config/db.js';
import routes from './src/routes/v1.js';
import { User } from './src/models/User.js';
const fastify = Fastify({ logger: true });
await connectDB();
const allowedOrigins = [
'http://localhost:5173',
'http://localhost',
'capacitor://localhost',
'https://10.0.2.2:5173',
'https://zenkanji.crylia.de'
];
if (process.env.CORS_ORIGINS) {
const prodOrigins = process.env.CORS_ORIGINS.split(',');
allowedOrigins.push(...prodOrigins);
}
await fastify.register(cors, {
origin: allowedOrigins,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
credentials: true
});
await fastify.register(jwt, {
secret: JWT_SECRET
});
fastify.decorate('authenticate', async function (req, reply) {
try {
const payload = await req.jwtVerify();
const user = await User.findById(payload.userId);
if (!user) {
reply.code(401).send({ message: 'User not found', code: 'INVALID_USER' });
return;
}
if (payload.version !== user.tokenVersion) {
reply.code(401).send({ message: 'Session invalid', code: 'INVALID_SESSION' });
return;
}
if (payload.version !== user.tokenVersion) {
throw new Error('Session invalid');
}
req.user = user;
} catch (err) {
reply.code(401).send(err);
}
});
await fastify.register(routes);
const start = async () => {
try {
await fastify.listen({ port: PORT, host: '0.0.0.0' });
console.log(`Server running at http://localhost:${PORT}`);
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
start();

View File

@@ -0,0 +1,4 @@
export const PORT = process.env.PORT || 3000;
export const MONGO_URI = process.env.MONGO_URI || 'mongodb://mongo:27017/zenkanji';
export const SRS_TIMINGS_HOURS = [0, 0, 4, 8, 23, 47];
export const JWT_SECRET = process.env.JWT_SECRET;

12
server/src/config/db.js Normal file
View File

@@ -0,0 +1,12 @@
import mongoose from 'mongoose';
import { MONGO_URI } from './constants.js';
export const connectDB = async () => {
try {
await mongoose.connect(MONGO_URI);
console.log('✅ Connected to MongoDB');
} catch (err) {
console.error('❌ MongoDB Connection Error:', err);
process.exit(1);
}
};

View File

@@ -0,0 +1,28 @@
import * as AuthService from '../services/auth.service.js';
export const login = async (req, reply) => {
const { apiKey } = req.body;
if (!apiKey) return reply.code(400).send({ error: 'API Key required' });
try {
const user = await AuthService.loginUser(apiKey);
const token = await reply.jwtSign(
{ userId: user._id, version: user.tokenVersion },
{ expiresIn: '365d' }
);
return {
success: true,
token,
user: { settings: user.settings, stats: user.stats }
};
} catch (err) {
return reply.code(401).send({ error: err.message });
}
};
export const logout = async (req, reply) => {
await AuthService.logoutUser(req.user._id);
return { success: true };
};

View File

@@ -0,0 +1,33 @@
import { User } from '../models/User.js';
import * as ReviewService from '../services/review.service.js';
import * as StatsService from '../services/stats.service.js';
import { StudyItem } from '../models/StudyItem.js';
export const getCollection = async (req, reply) => {
const items = await StudyItem.find({ userId: req.user._id });
return reply.send(items);
};
export const getQueue = async (req, reply) => {
const { limit, sort } = req.query;
const queue = await ReviewService.getQueue(req.user, parseInt(limit) || 20, sort);
return reply.send(queue);
};
export const getStats = async (req, reply) => {
const stats = await StatsService.getUserStats(req.user);
return reply.send(stats);
};
export const updateSettings = async (req, reply) => {
const { batchSize, drawingAccuracy } = req.body;
const user = req.user;
if (!user.settings) user.settings = {};
if (batchSize) user.settings.batchSize = batchSize;
if (drawingAccuracy) user.settings.drawingAccuracy = drawingAccuracy;
await user.save();
return reply.send({ success: true, settings: user.settings });
};

View File

@@ -0,0 +1,11 @@
import * as ReviewService from '../services/review.service.js';
export const submitReview = async (req, reply) => {
const { subjectId, success } = req.body;
try {
const result = await ReviewService.processReview(req.user, subjectId, success);
return reply.send(result);
} catch (err) {
return reply.code(404).send({ error: err.message });
}
};

View File

@@ -0,0 +1,10 @@
import * as SyncService from '../services/sync.service.js';
export const sync = async (req, reply) => {
try {
const result = await SyncService.syncWithWaniKani(req.user);
return reply.send(result);
} catch (error) {
return reply.code(500).send({ error: error.message });
}
};

View File

@@ -0,0 +1,10 @@
import mongoose from 'mongoose';
const reviewLogSchema = new mongoose.Schema({
userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
date: { type: String, required: true },
count: { type: Number, default: 0 }
});
reviewLogSchema.index({ userId: 1, date: 1 }, { unique: true });
export const ReviewLog = mongoose.model('ReviewLog', reviewLogSchema);

View File

@@ -0,0 +1,21 @@
import mongoose from 'mongoose';
const studyItemSchema = new mongoose.Schema({
userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
wkSubjectId: { type: Number, required: true },
char: { type: String, required: true },
meaning: { type: String, required: true },
level: { type: Number, required: true },
srsLevel: { type: Number, default: 0 },
nextReview: { type: Date, default: Date.now },
onyomi: { type: [String], default: [] },
kunyomi: { type: [String], default: [] },
nanori: { type: [String], default: [] },
stats: {
correct: { type: Number, default: 0 },
total: { type: Number, default: 0 }
}
});
studyItemSchema.index({ userId: 1, wkSubjectId: 1 }, { unique: true });
export const StudyItem = mongoose.model('StudyItem', studyItemSchema);

21
server/src/models/User.js Normal file
View File

@@ -0,0 +1,21 @@
import mongoose from 'mongoose';
const userSchema = new mongoose.Schema({
wkApiKey: { type: String, required: true, unique: true },
lastSync: { type: Date, default: Date.now },
settings: {
batchSize: { type: Number, default: 20 },
drawingAccuracy: { type: Number, default: 10 }
},
tokenVersion: { type: Number, default: 0 },
stats: {
totalReviews: { type: Number, default: 0 },
correctReviews: { type: Number, default: 0 },
currentStreak: { type: Number, default: 0 },
maxStreak: { type: Number, default: 0 },
lastStudyDate: { type: String, default: null },
lastFreezeDate: { type: Date, default: null }
}
});
export const User = mongoose.model('User', userSchema);

22
server/src/routes/v1.js Normal file
View File

@@ -0,0 +1,22 @@
import { login, logout } from '../controllers/auth.controller.js';
import { sync } from '../controllers/sync.controller.js';
import { submitReview } from '../controllers/review.controller.js';
import { getStats, getQueue, getCollection, updateSettings } from '../controllers/collection.controller.js';
async function routes(fastify, options) {
fastify.post('/api/auth/login', login);
fastify.register(async (privateParams) => {
privateParams.addHook('onRequest', fastify.authenticate);
privateParams.post('/api/auth/logout', logout);
privateParams.post('/api/sync', sync);
privateParams.post('/api/review', submitReview);
privateParams.get('/api/stats', getStats);
privateParams.get('/api/queue', getQueue);
privateParams.get('/api/collection', getCollection);
privateParams.post('/api/settings', updateSettings);
});
}
export default routes;

View File

@@ -0,0 +1,29 @@
import { User } from '../models/User.js';
export const loginUser = async (apiKey) => {
const response = await fetch('https://api.wanikani.com/v2/user', {
headers: { Authorization: `Bearer ${apiKey}` }
});
if (response.status !== 200) {
throw new Error('Invalid API Key');
}
let user = await User.findOne({ wkApiKey: apiKey });
if (!user) {
user = await User.create({
wkApiKey: apiKey,
tokenVersion: 0,
stats: { totalReviews: 0, correctReviews: 0, currentStreak: 0, maxStreak: 0 },
settings: { batchSize: 20 }
});
}
return user;
};
export const logoutUser = async (userId) => {
await User.findByIdAndUpdate(userId, { $inc: { tokenVersion: 1 } });
return true;
};

View File

@@ -0,0 +1,85 @@
import { ReviewLog } from '../models/ReviewLog.js';
import { StudyItem } from '../models/StudyItem.js';
import { getDaysDiff, getSRSDate } from '../utils/dateUtils.js';
export const processReview = async (user, subjectId, success) => {
if (!user.stats) user.stats = { totalReviews: 0, correctReviews: 0, currentStreak: 0, maxStreak: 0 };
user.stats.totalReviews += 1;
if (success) user.stats.correctReviews += 1;
const todayStr = new Date().toISOString().split('T')[0];
const lastStudyStr = user.stats.lastStudyDate;
if (lastStudyStr !== todayStr) {
if (!lastStudyStr) {
user.stats.currentStreak = 1;
} else {
const diff = getDaysDiff(lastStudyStr, todayStr);
if (diff === 1) {
user.stats.currentStreak += 1;
} else if (diff > 1) {
const lastFreeze = user.stats.lastFreezeDate ? new Date(user.stats.lastFreezeDate) : null;
let daysSinceFreeze = 999;
if (lastFreeze) daysSinceFreeze = getDaysDiff(lastFreeze.toISOString().split('T')[0], todayStr);
const canUseShield = daysSinceFreeze >= 7;
if (canUseShield && diff === 2) {
console.log(`User ${user._id} saved by Zen Shield!`);
user.stats.lastFreezeDate = new Date();
user.stats.currentStreak += 1;
} else {
user.stats.currentStreak = 1;
}
}
}
user.stats.lastStudyDate = todayStr;
if (user.stats.currentStreak > user.stats.maxStreak) user.stats.maxStreak = user.stats.currentStreak;
}
await user.save();
await ReviewLog.findOneAndUpdate(
{ userId: user._id, date: todayStr },
{ $inc: { count: 1 } },
{ upsert: true, new: true }
);
const item = await StudyItem.findOne({ userId: user._id, wkSubjectId: subjectId });
if (!item) throw new Error('Item not found');
if (!item.stats) item.stats = { correct: 0, total: 0 };
item.stats.total += 1;
if (success) item.stats.correct += 1;
if (success) {
const nextLevel = Math.min(item.srsLevel + 1, 10);
item.srsLevel = nextLevel;
item.nextReview = getSRSDate(nextLevel);
} else {
item.srsLevel = Math.max(1, item.srsLevel - 1);
item.nextReview = Date.now();
}
await item.save();
return { nextReview: item.nextReview, srsLevel: item.srsLevel };
};
export const getQueue = async (user, limit = 100, sortMode) => {
const query = {
userId: user._id,
srsLevel: { $lt: 10, $gt: 0 },
nextReview: { $lte: new Date() }
};
let dueItems;
if (sortMode === 'priority') {
dueItems = await StudyItem.find(query).sort({ srsLevel: 1, level: 1 }).limit(limit);
} else {
dueItems = await StudyItem.find(query).limit(limit);
for (let i = dueItems.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[dueItems[i], dueItems[j]] = [dueItems[j], dueItems[i]];
}
}
return dueItems;
};

View File

@@ -0,0 +1,112 @@
import { StudyItem } from '../models/StudyItem.js';
import { ReviewLog } from '../models/ReviewLog.js';
import { getDaysDiff } from '../utils/dateUtils.js';
export const getUserStats = async (user) => {
const userId = user._id;
const srsCounts = await StudyItem.aggregate([
{ $match: { userId: userId } },
{ $group: { _id: "$srsLevel", count: { $sum: 1 } } }
]);
const dist = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0 };
srsCounts.forEach(g => { if (dist[g._id] !== undefined) dist[g._id] = g.count; });
const now = new Date();
const next24h = new Date(now.getTime() + 24 * 60 * 60 * 1000);
const upcoming = await StudyItem.find({
userId: userId,
srsLevel: { $lt: 6, $gt: 0 },
nextReview: { $lte: next24h }
}).select('nextReview');
const forecast = new Array(24).fill(0);
upcoming.forEach(item => {
const diff = Math.floor((new Date(item.nextReview) - now) / 3600000);
if (diff < 0) forecast[0]++;
else if (diff < 24) forecast[diff]++;
});
const queueItems = await StudyItem.find({
userId: userId,
srsLevel: { $lt: 6, $gt: 0 },
nextReview: { $lte: now }
}).select('srsLevel');
const queueCount = queueItems.length;
let hasLowerLevels = false;
let lowerLevelCount = 0;
if (queueCount > 0) {
const levels = queueItems.map(i => i.srsLevel);
const minSrs = Math.min(...levels);
const maxSrs = Math.max(...levels);
if (minSrs < maxSrs) {
hasLowerLevels = true;
lowerLevelCount = queueItems.filter(i => i.srsLevel === minSrs).length;
}
}
const oneYearAgo = new Date();
oneYearAgo.setDate(oneYearAgo.getDate() - 365);
const dateStr = oneYearAgo.toISOString().split('T')[0];
const logs = await ReviewLog.find({ userId: userId, date: { $gte: dateStr } });
const heatmap = {};
logs.forEach(l => { heatmap[l.date] = l.count; });
const todayStr = new Date().toISOString().split('T')[0];
const lastStudyStr = user.stats.lastStudyDate || null;
let displayStreak = user.stats.currentStreak || 0;
if (lastStudyStr) {
const diff = getDaysDiff(lastStudyStr, todayStr);
if (diff > 1) displayStreak = 0;
}
const lastFreeze = user.stats.lastFreezeDate ? new Date(user.stats.lastFreezeDate) : null;
let daysSinceFreeze = 999;
if (lastFreeze) {
daysSinceFreeze = getDaysDiff(lastFreeze.toISOString().split('T')[0], todayStr);
}
const shieldReady = daysSinceFreeze >= 7;
const shieldCooldown = shieldReady ? 0 : (7 - daysSinceFreeze);
const history7Days = [];
for (let i = 6; i >= 0; i--) {
const d = new Date();
d.setDate(d.getDate() - i);
const dStr = d.toISOString().split('T')[0];
const count = heatmap[dStr] || 0;
history7Days.push({ date: dStr, active: count > 0 });
}
const allGhosts = await StudyItem.find({ userId: userId, 'stats.total': { $gte: 2 } })
.select('char meaning stats srsLevel');
const sortedGhosts = allGhosts.map(item => ({
...item.toObject(),
accuracy: Math.round((item.stats.correct / item.stats.total) * 100)
})).filter(i => i.accuracy < 85)
.sort((a, b) => a.accuracy - b.accuracy)
.slice(0, 4);
return {
distribution: dist,
forecast: forecast,
queueLength: queueCount,
hasLowerLevels,
lowerLevelCount,
heatmap: heatmap,
ghosts: sortedGhosts,
streak: {
current: displayStreak,
best: user.stats.maxStreak || 0,
shield: { ready: shieldReady, cooldown: Math.max(0, shieldCooldown) },
history: history7Days
},
accuracy: {
total: user.stats.totalReviews || 0,
correct: user.stats.correctReviews || 0
}
};
};

View File

@@ -0,0 +1,78 @@
import { User } from '../models/User.js';
import { StudyItem } from '../models/StudyItem.js';
export const syncWithWaniKani = async (user) => {
const apiKey = user.wkApiKey;
if (!apiKey) {
throw new Error('User has no WaniKani API Key');
}
console.log(`Starting sync for user: ${user._id}`);
let allSubjectIds = [];
let nextUrl = 'https://api.wanikani.com/v2/assignments?subject_types=kanji&started=true';
try {
while (nextUrl) {
const res = await fetch(nextUrl, {
headers: { Authorization: `Bearer ${apiKey}` }
});
if (!res.ok) {
throw new Error(`WaniKani API Error: ${res.statusText}`);
}
const json = await res.json();
allSubjectIds = allSubjectIds.concat(json.data.map(d => d.data.subject_id));
nextUrl = json.pages.next_url;
}
} catch (err) {
console.error("Error fetching assignments:", err);
throw new Error("Failed to connect to WaniKani. Check your internet connection.");
}
if (allSubjectIds.length === 0) {
return { count: 0, message: "No unlocked kanji found.", settings: user.settings };
}
const existingItems = await StudyItem.find({ userId: user._id }).select('wkSubjectId');
const existingIds = new Set(existingItems.map(i => i.wkSubjectId));
const newIds = allSubjectIds.filter(id => !existingIds.has(id));
console.log(`Found ${newIds.length} new items to download.`);
const CHUNK_SIZE = 100;
for (let i = 0; i < newIds.length; i += CHUNK_SIZE) {
const chunk = newIds.slice(i, i + CHUNK_SIZE);
const subRes = await fetch(`https://api.wanikani.com/v2/subjects?ids=${chunk.join(',')}`, {
headers: { Authorization: `Bearer ${apiKey}` }
});
const subJson = await subRes.json();
const operations = subJson.data.map(d => {
const readings = d.data.readings || [];
return {
userId: user._id,
wkSubjectId: d.id,
char: d.data.characters,
meaning: d.data.meanings.find(m => m.primary)?.meaning || 'Unknown',
level: d.data.level,
srsLevel: 1,
nextReview: Date.now(),
onyomi: readings.filter(r => r.type === 'onyomi').map(r => r.reading),
kunyomi: readings.filter(r => r.type === 'kunyomi').map(r => r.reading),
nanori: readings.filter(r => r.type === 'nanori').map(r => r.reading),
stats: { correct: 0, total: 0 }
};
});
if (operations.length > 0) {
await StudyItem.insertMany(operations);
}
}
const finalCount = await StudyItem.countDocuments({ userId: user._id });
return { success: true, count: finalCount, settings: user.settings };
};

View File

@@ -0,0 +1,32 @@
export const getDaysDiff = (date1Str, date2Str) => {
const d1 = new Date(date1Str);
const d2 = new Date(date2Str);
const diff = d2 - d1;
return Math.floor(diff / (1000 * 60 * 60 * 24));
};
export const getSRSDate = (level) => {
const now = new Date();
let hoursToAdd = 0;
switch (level) {
case 1: hoursToAdd = 4; break;
case 2: hoursToAdd = 8; break;
case 3: hoursToAdd = 24; break;
case 4: hoursToAdd = 2 * 24; break;
case 5: hoursToAdd = 7 * 24; break;
case 6: hoursToAdd = 14 * 24; break;
case 7: hoursToAdd = 7 * 24; break;
case 8: hoursToAdd = 30 * 24; break;
case 9: hoursToAdd = 90 * 24; break;
case 10: break;
default: hoursToAdd = 4;
}
if (hoursToAdd === 0) return null;
now.setUTCHours(now.getUTCHours() + hoursToAdd);
return now;
};

View File

@@ -0,0 +1,52 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { loginUser, logoutUser } from '../../src/services/auth.service.js';
import { User } from '../../src/models/User.js';
vi.mock('../../src/models/User.js');
global.fetch = vi.fn();
describe('Auth Service', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('loginUser', () => {
it('should throw error for invalid API Key', async () => {
fetch.mockResolvedValue({ status: 401 });
await expect(loginUser('bad_key')).rejects.toThrow('Invalid API Key');
});
it('should return existing user if found', async () => {
fetch.mockResolvedValue({ status: 200 });
const mockUser = { wkApiKey: 'valid_key', _id: '123' };
User.findOne.mockResolvedValue(mockUser);
const result = await loginUser('valid_key');
expect(result).toEqual(mockUser);
expect(User.create).not.toHaveBeenCalled();
});
it('should create new user if not found', async () => {
fetch.mockResolvedValue({ status: 200 });
User.findOne.mockResolvedValue(null);
const newUser = { wkApiKey: 'valid_key', _id: 'new_id' };
User.create.mockResolvedValue(newUser);
const result = await loginUser('valid_key');
expect(result).toEqual(newUser);
expect(User.create).toHaveBeenCalledWith(expect.objectContaining({
wkApiKey: 'valid_key',
tokenVersion: 0
}));
});
});
describe('logoutUser', () => {
it('should increment token version', async () => {
User.findByIdAndUpdate.mockResolvedValue(true);
const result = await logoutUser('userId');
expect(User.findByIdAndUpdate).toHaveBeenCalledWith('userId', { $inc: { tokenVersion: 1 } });
expect(result).toBe(true);
});
});
});

View File

@@ -0,0 +1,142 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as AuthController from '../../src/controllers/auth.controller.js';
import * as ReviewController from '../../src/controllers/review.controller.js';
import * as SyncController from '../../src/controllers/sync.controller.js';
import * as CollectionController from '../../src/controllers/collection.controller.js';
import * as AuthService from '../../src/services/auth.service.js';
import * as ReviewService from '../../src/services/review.service.js';
import * as SyncService from '../../src/services/sync.service.js';
import * as StatsService from '../../src/services/stats.service.js';
import { StudyItem } from '../../src/models/StudyItem.js';
vi.mock('../../src/services/auth.service.js');
vi.mock('../../src/services/review.service.js');
vi.mock('../../src/services/sync.service.js');
vi.mock('../../src/services/stats.service.js');
vi.mock('../../src/models/StudyItem.js');
const mockReq = (body = {}, user = {}, query = {}) => ({ body, user, query });
const mockReply = () => {
const res = {};
res.code = vi.fn().mockReturnValue(res);
res.send = vi.fn().mockReturnValue(res);
res.jwtSign = vi.fn().mockResolvedValue('token');
return res;
};
describe('Controllers', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('Auth Controller', () => {
it('login should fail without apiKey', async () => {
const reply = mockReply();
await AuthController.login(mockReq({}), reply);
expect(reply.code).toHaveBeenCalledWith(400);
});
it('login should succeed', async () => {
const reply = mockReply();
AuthService.loginUser.mockResolvedValue({ _id: 1, tokenVersion: 1 });
await AuthController.login(mockReq({ apiKey: 'key' }), reply);
expect(reply.jwtSign).toHaveBeenCalled();
expect(reply.code).not.toHaveBeenCalledWith(401);
});
it('login should catch errors', async () => {
const reply = mockReply();
AuthService.loginUser.mockRejectedValue(new Error('fail'));
await AuthController.login(mockReq({ apiKey: 'k' }), reply);
expect(reply.code).toHaveBeenCalledWith(401);
});
it('logout should succeed', async () => {
const reply = mockReply();
await AuthController.logout(mockReq({}, { _id: 1 }), reply);
expect(AuthService.logoutUser).toHaveBeenCalled();
});
});
describe('Review Controller', () => {
it('submitReview should succeed', async () => {
const reply = mockReply();
ReviewService.processReview.mockResolvedValue({});
await ReviewController.submitReview(mockReq({}), reply);
expect(reply.send).toHaveBeenCalled();
});
it('submitReview should handle error', async () => {
const reply = mockReply();
ReviewService.processReview.mockRejectedValue(new Error('err'));
await ReviewController.submitReview(mockReq({}), reply);
expect(reply.code).toHaveBeenCalledWith(404);
});
});
describe('Sync Controller', () => {
it('sync should succeed', async () => {
const reply = mockReply();
SyncService.syncWithWaniKani.mockResolvedValue({});
await SyncController.sync(mockReq({}, {}), reply);
expect(reply.send).toHaveBeenCalled();
});
it('sync should handle error', async () => {
const reply = mockReply();
SyncService.syncWithWaniKani.mockRejectedValue(new Error('err'));
await SyncController.sync(mockReq({}, {}), reply);
expect(reply.code).toHaveBeenCalledWith(500);
});
});
describe('Collection Controller', () => {
it('getCollection should return items', async () => {
const reply = mockReply();
StudyItem.find.mockResolvedValue([]);
await CollectionController.getCollection(mockReq({}, { _id: 1 }), reply);
expect(reply.send).toHaveBeenCalledWith([]);
});
it('getQueue should call service with default limit', async () => {
const reply = mockReply();
ReviewService.getQueue.mockResolvedValue([]);
await CollectionController.getQueue(mockReq({}, {}, {}), reply);
expect(ReviewService.getQueue).toHaveBeenCalledWith(expect.anything(), 20, undefined);
});
it('getQueue should call service with provided limit', async () => {
const reply = mockReply();
ReviewService.getQueue.mockResolvedValue([]);
await CollectionController.getQueue(mockReq({}, {}, { limit: '50' }), reply);
expect(ReviewService.getQueue).toHaveBeenCalledWith(expect.anything(), 50, undefined);
});
it('getStats should call service', async () => {
const reply = mockReply();
StatsService.getUserStats.mockResolvedValue({});
await CollectionController.getStats(mockReq({}, {}), reply);
expect(StatsService.getUserStats).toHaveBeenCalled();
});
it('updateSettings should initialize settings if missing', async () => {
const reply = mockReply();
const save = vi.fn();
const user = { save };
await CollectionController.updateSettings(mockReq({ batchSize: 50 }, user), reply);
expect(user.settings).toBeDefined();
expect(user.settings.batchSize).toBe(50);
expect(save).toHaveBeenCalled();
});
it('updateSettings should update drawingAccuracy', async () => {
const reply = mockReply();
const save = vi.fn();
const user = { settings: {}, save };
await CollectionController.updateSettings(mockReq({ drawingAccuracy: 5 }, user), reply);
expect(user.settings.drawingAccuracy).toBe(5);
expect(save).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,144 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
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';
vi.mock('../../src/models/ReviewLog.js');
vi.mock('../../src/models/StudyItem.js');
describe('Review Service', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2023-01-10T00:00:00Z'));
});
afterEach(() => {
vi.useRealTimers();
vi.resetAllMocks();
});
describe('processReview', () => {
const mockUser = (stats = {}) => ({
_id: 'u1',
stats: { ...stats },
save: vi.fn()
});
const mockItem = (srs = 1) => ({
userId: 'u1',
wkSubjectId: 100,
srsLevel: srs,
stats: { correct: 0, total: 0 },
save: vi.fn(),
nextReview: null
});
it('should throw error if item not found', async () => {
const user = mockUser();
StudyItem.findOne.mockResolvedValue(null);
await expect(ReviewService.processReview(user, 999, true))
.rejects.toThrow('Item not found');
expect(StudyItem.findOne).toHaveBeenCalledWith({
userId: user._id,
wkSubjectId: 999
});
});
it('should handle standard success flow', async () => {
const user = mockUser();
delete user.stats;
StudyItem.findOne.mockResolvedValue(mockItem());
const res = await ReviewService.processReview(user, 100, true);
expect(user.save).toHaveBeenCalled();
expect(res.srsLevel).toBe(2);
});
it('should handle failure flow', 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 () => {
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();
});
it('should use shield (diff = 2) when lastFreezeDate is undefined', async () => {
const user = mockUser({ lastStudyDate: '2023-01-08', currentStreak: 5 });
user.stats.lastFreezeDate = null;
StudyItem.findOne.mockResolvedValue(mockItem());
await ReviewService.processReview(user, 100, true);
expect(user.stats.currentStreak).toBe(6);
expect(user.stats.lastFreezeDate).toBeDefined();
});
it('should initialize item stats if missing', async () => {
const user = mockUser();
const item = mockItem();
delete item.stats;
StudyItem.findOne.mockResolvedValue(item);
await ReviewService.processReview(user, 100, true);
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());
await ReviewService.processReview(user, 100, true);
expect(user.stats.lastStudyDate).toBe('2023-01-10');
expect(user.stats.currentStreak).toBe(5);
});
});
describe('getQueue', () => {
it('should sort by priority', async () => {
const mockFind = { sort: vi.fn().mockReturnThis(), limit: vi.fn().mockResolvedValue([]) };
StudyItem.find.mockReturnValue(mockFind);
await ReviewService.getQueue({ _id: 'u1' }, 10, 'priority');
expect(mockFind.sort).toHaveBeenCalled();
});
it('should shuffle (default)', async () => {
const mockFind = { limit: vi.fn().mockResolvedValue([1, 2]) };
StudyItem.find.mockReturnValue(mockFind);
await ReviewService.getQueue({ _id: 'u1' }, 10, 'random');
expect(mockFind.limit).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,147 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { getUserStats } from '../../src/services/stats.service.js';
import { StudyItem } from '../../src/models/StudyItem.js';
import { ReviewLog } from '../../src/models/ReviewLog.js';
vi.mock('../../src/models/StudyItem.js');
vi.mock('../../src/models/ReviewLog.js');
describe('Stats Service', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2023-01-10T12:00:00Z'));
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
const mockUser = {
_id: 'u1',
stats: {
totalReviews: 10,
correctReviews: 8,
currentStreak: 5,
lastStudyDate: '2023-01-10',
lastFreezeDate: '2022-01-01'
}
};
it('should calculate stats correctly including ghosts, forecast, and unknown SRS levels', async () => {
StudyItem.aggregate.mockResolvedValue([
{ _id: 1, count: 5 },
{ _id: 8, count: 2 }
]);
StudyItem.find.mockImplementation(() => ({
select: vi.fn().mockImplementation((fields) => {
if (fields.includes('nextReview')) {
const now = new Date();
return Promise.resolve([
{ nextReview: new Date(now.getTime() - 1000) },
{ nextReview: new Date(now.getTime() + 3600000) },
{ nextReview: new Date(now.getTime() + 24 * 3600000) }
]);
}
if (fields.includes('stats')) {
const mkGhost = (acc) => ({
char: 'A', meaning: 'B', stats: { correct: acc, total: 100 }, srsLevel: 1,
toObject: () => ({ char: 'A', meaning: 'B', stats: { correct: acc, total: 100 }, srsLevel: 1 })
});
return Promise.resolve([mkGhost(60), mkGhost(50), mkGhost(90)]);
}
if (fields.includes('srsLevel')) {
return Promise.resolve([{ srsLevel: 1 }, { srsLevel: 4 }]);
}
return Promise.resolve([]);
})
}));
ReviewLog.find.mockResolvedValue([
{ date: '2023-01-09', count: 5 }
]);
const stats = await getUserStats(mockUser);
expect(stats.forecast[0]).toBe(1);
expect(stats.forecast[1]).toBe(1);
expect(stats.ghosts.length).toBe(2);
expect(stats.ghosts[0].accuracy).toBe(50);
expect(stats.ghosts[1].accuracy).toBe(60);
expect(stats.distribution[1]).toBe(5);
expect(stats.distribution[8]).toBeUndefined();
expect(stats.heatmap['2023-01-09']).toBe(5);
});
it('should handle queue with same levels (hasLowerLevels = false)', async () => {
StudyItem.aggregate.mockResolvedValue([]);
StudyItem.find.mockImplementation(() => ({
select: vi.fn().mockImplementation((fields) => {
if (fields.includes('stats')) {
return Promise.resolve([]);
}
if (fields.includes('srsLevel')) {
return Promise.resolve([{ srsLevel: 2 }, { srsLevel: 2 }]);
}
return Promise.resolve([]);
})
}));
ReviewLog.find.mockResolvedValue([]);
const stats = await getUserStats(mockUser);
expect(stats.hasLowerLevels).toBe(false);
});
it('should calculate shield cooldown', async () => {
StudyItem.aggregate.mockResolvedValue([]);
StudyItem.find.mockReturnValue({ select: vi.fn().mockResolvedValue([]) });
ReviewLog.find.mockResolvedValue([]);
const frozenUser = {
...mockUser,
stats: { ...mockUser.stats, lastFreezeDate: '2023-01-07' }
};
const stats = await getUserStats(frozenUser);
expect(stats.streak.shield.ready).toBe(false);
expect(stats.streak.shield.cooldown).toBe(4);
});
it('should handle streak logic when lastStudyDate is missing or old', async () => {
const userNoDate = { ...mockUser, stats: { ...mockUser.stats, lastStudyDate: null, currentStreak: 5 } };
StudyItem.aggregate.mockResolvedValue([]);
StudyItem.find.mockReturnValue({ select: vi.fn().mockResolvedValue([]) });
ReviewLog.find.mockResolvedValue([]);
const res1 = await getUserStats(userNoDate);
expect(res1.streak.current).toBe(5);
const userMissed = { ...mockUser, stats: { ...mockUser.stats, lastStudyDate: '2023-01-08' } };
const res2 = await getUserStats(userMissed);
expect(res2.streak.current).toBe(0);
});
it('should handle missing user stats fields (null checks)', async () => {
const emptyUser = {
_id: 'u2',
stats: {}
};
StudyItem.aggregate.mockResolvedValue([]);
StudyItem.find.mockReturnValue({ select: vi.fn().mockResolvedValue([]) });
ReviewLog.find.mockResolvedValue([]);
const stats = await getUserStats(emptyUser);
expect(stats.streak.current).toBe(0);
expect(stats.streak.best).toBe(0);
expect(stats.accuracy.total).toBe(0);
expect(stats.accuracy.correct).toBe(0);
expect(stats.streak.shield.ready).toBe(true);
});
});

View File

@@ -0,0 +1,212 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { syncWithWaniKani } from '../../src/services/sync.service.js';
import { StudyItem } from '../../src/models/StudyItem.js';
vi.mock('../../src/models/StudyItem.js');
global.fetch = vi.fn();
describe('Sync Service', () => {
beforeEach(() => {
vi.clearAllMocks();
});
const mockUser = { _id: 'u1', wkApiKey: 'key', settings: {} };
it('should throw if no API key', async () => {
await expect(syncWithWaniKani({}))
.rejects.toThrow('User has no WaniKani API Key');
});
it('should handle API fetch error', async () => {
fetch.mockResolvedValue({ ok: false, statusText: 'Unauthorized' });
await expect(syncWithWaniKani(mockUser))
.rejects.toThrow('Failed to connect to WaniKani');
});
it('should return 0 if no unlocked kanji', async () => {
fetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: [], pages: { next_url: null } })
});
const res = await syncWithWaniKani(mockUser);
expect(res.count).toBe(0);
expect(res.message).toContain('No unlocked kanji');
});
it('should sync new items with full reading data', async () => {
fetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: [{ data: { subject_id: 101 } }],
pages: { next_url: null }
})
});
StudyItem.find.mockReturnValue({ select: vi.fn().mockResolvedValue([]) });
fetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: [{
id: 101,
data: {
characters: 'A',
meanings: [{ primary: true, meaning: 'A_Meaning' }],
level: 5,
readings: [
{ type: 'onyomi', reading: 'on' },
{ type: 'kunyomi', reading: 'kun' },
{ type: 'nanori', reading: 'nan' }
]
}
}]
})
});
StudyItem.insertMany.mockResolvedValue(true);
StudyItem.countDocuments.mockResolvedValue(1);
await syncWithWaniKani(mockUser);
expect(StudyItem.insertMany).toHaveBeenCalledWith(expect.arrayContaining([
expect.objectContaining({
wkSubjectId: 101,
onyomi: ['on'],
kunyomi: ['kun'],
nanori: ['nan']
})
]));
});
it('should filter out existing items', async () => {
fetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: [
{ data: { subject_id: 101 } },
{ data: { subject_id: 102 } }
],
pages: { next_url: null }
})
});
StudyItem.find.mockReturnValue({
select: vi.fn().mockResolvedValue([{ wkSubjectId: 102 }])
});
fetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: [{
id: 101,
data: {
characters: 'New',
meanings: [],
level: 1,
readings: []
}
}]
})
});
StudyItem.insertMany.mockResolvedValue(true);
StudyItem.countDocuments.mockResolvedValue(2);
await syncWithWaniKani(mockUser);
expect(StudyItem.insertMany).toHaveBeenCalledTimes(1);
expect(StudyItem.insertMany).toHaveBeenCalledWith(expect.arrayContaining([
expect.objectContaining({ wkSubjectId: 101 })
]));
expect(StudyItem.insertMany).not.toHaveBeenCalledWith(expect.arrayContaining([
expect.objectContaining({ wkSubjectId: 102 })
]));
});
it('should handle assignment pagination', async () => {
fetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: [{ data: { subject_id: 1 } }],
pages: { next_url: 'http://next-page' }
})
});
fetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: [{ data: { subject_id: 2 } }],
pages: { next_url: null }
})
});
StudyItem.find.mockReturnValue({ select: vi.fn().mockResolvedValue([]) });
fetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: [] })
});
StudyItem.countDocuments.mockResolvedValue(2);
await syncWithWaniKani(mockUser);
expect(fetch).toHaveBeenCalledTimes(3);
});
it('should skip insert if operations are empty (e.g. subject data missing)', async () => {
fetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: [{ data: { subject_id: 101 } }],
pages: { next_url: null }
})
});
StudyItem.find.mockReturnValue({ select: vi.fn().mockResolvedValue([]) });
fetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: [] })
});
StudyItem.countDocuments.mockResolvedValue(0);
await syncWithWaniKani(mockUser);
expect(StudyItem.insertMany).not.toHaveBeenCalled();
});
it('should sync items in chunks', async () => {
const manyIds = Array.from({ length: 150 }, (_, i) => i + 1);
const subjectData = manyIds.map(id => ({ data: { subject_id: id } }));
fetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: subjectData,
pages: { next_url: null }
})
});
StudyItem.find.mockReturnValue({ select: vi.fn().mockResolvedValue([]) });
fetch
.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: [{ id: 1, data: { characters: 'C', meanings: [], level: 1 } }] })
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: [{ id: 101, data: { characters: 'D', meanings: [], level: 1 } }] })
});
StudyItem.insertMany.mockResolvedValue(true);
StudyItem.countDocuments.mockResolvedValue(150);
await syncWithWaniKani(mockUser);
expect(fetch).toHaveBeenCalledTimes(3);
expect(StudyItem.insertMany).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,54 @@
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import { getDaysDiff, getSRSDate } from '../src/utils/dateUtils.js';
describe('Date Utils', () => {
describe('getDaysDiff', () => {
it('should calculate difference between two dates correctly', () => {
const d1 = '2023-01-01';
const d2 = '2023-01-03';
expect(getDaysDiff(d1, d2)).toBe(2);
});
it('should return 0 for same day', () => {
expect(getDaysDiff('2023-01-01', '2023-01-01')).toBe(0);
});
});
describe('getSRSDate', () => {
beforeAll(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2023-01-01T12:00:00Z'));
});
afterAll(() => {
vi.useRealTimers();
});
it('should add correct hours for levels 1-6', () => {
const base = new Date('2023-01-01T12:00:00Z');
expect(getSRSDate(1).toISOString()).toBe(new Date(base.getTime() + 4 * 3600000).toISOString());
expect(getSRSDate(2).toISOString()).toBe(new Date(base.getTime() + 8 * 3600000).toISOString());
expect(getSRSDate(3).toISOString()).toBe(new Date(base.getTime() + 24 * 3600000).toISOString());
expect(getSRSDate(4).toISOString()).toBe(new Date(base.getTime() + 48 * 3600000).toISOString());
expect(getSRSDate(5).toISOString()).toBe(new Date(base.getTime() + 7 * 24 * 3600000).toISOString());
expect(getSRSDate(6).toISOString()).toBe(new Date(base.getTime() + 14 * 24 * 3600000).toISOString());
});
it('should add correct hours for levels 7-9', () => {
const base = new Date('2023-01-01T12:00:00Z');
expect(getSRSDate(7).toISOString()).toBe(new Date(base.getTime() + 7 * 24 * 3600000).toISOString());
expect(getSRSDate(8).toISOString()).toBe(new Date(base.getTime() + 30 * 24 * 3600000).toISOString());
expect(getSRSDate(9).toISOString()).toBe(new Date(base.getTime() + 90 * 24 * 3600000).toISOString());
});
it('should return null for level 10 (burned)', () => {
expect(getSRSDate(10)).toBeNull();
});
it('should default to 4 hours for unknown levels', () => {
const base = new Date('2023-01-01T12:00:00Z');
expect(getSRSDate(99).toISOString()).toBe(new Date(base.getTime() + 4 * 3600000).toISOString());
});
});
});