setIsPreferencesDialogOpen(false)}
+ />
+
>
);
}
diff --git a/src/header/components/UserPreferencesDialog.jsx b/src/header/components/UserPreferencesDialog.jsx
new file mode 100644
index 00000000..f78c2793
--- /dev/null
+++ b/src/header/components/UserPreferencesDialog.jsx
@@ -0,0 +1,93 @@
+import {
+ Button, Checkbox,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+ Tab,
+ Tabs, Tooltip, Typography
+} from "@mui/material";
+import React, {useState} from "react";
+import {useUserPreferences} from "../../hooks/useUserPreferences.jsx";
+import {HelpOutline} from "@mui/icons-material";
+
+function WanikaniPreferences() {
+ const {wanikaniPreferences: preferences, updateWanikaniPreferences} = useUserPreferences();
+ return (
+ <>
+
+ Dashboard
+
+
+
+
+ updateWanikaniPreferences({showPreviousLevelByDefault: !preferences.showPreviousLevelByDefault})}
+ />
+ Show Previous Level by Default
+
+ If you have not completed all Radicals, Kanji, and Vocabulary on the previous level,
+ automatically select the 'Show Previous Level' checkbox.
+ >
+ )}
+ >
+
+
+
+
+
+ >
+ );
+}
+
+function UserPreferencesDialog({isOpen, onClose}) {
+ const [tab, setTab] = useState(0);
+ return (
+
+ );
+}
+
+export default UserPreferencesDialog;
\ No newline at end of file
diff --git a/src/hooks/useUserPreferences.jsx b/src/hooks/useUserPreferences.jsx
new file mode 100644
index 00000000..f39634a9
--- /dev/null
+++ b/src/hooks/useUserPreferences.jsx
@@ -0,0 +1,29 @@
+import create from 'zustand'
+import {persist} from "zustand/middleware";
+
+const globalDefaultPreferences = {};
+const ankiDefaultPreferences = {};
+const bunProDefaultPreferences = {};
+const wanikaniDefaultPreferences = {
+ showPreviousLevelByDefault: true,
+};
+
+export const useUserPreferences = create(persist(
+ (set) => ({
+
+ globalPreferences: globalDefaultPreferences,
+ updateGlobalPreferences: (preferences) => set(() => ({globalPreferences: {...preferences}})),
+
+ ankiPreferences: ankiDefaultPreferences,
+ updateAnkiPreferences: (preferences) => set(() => ({ankiPreferences: {...preferences}})),
+
+ bunProPreferences: bunProDefaultPreferences,
+ updateBunProPreferences: (preferences) => set(() => ({bunProPreferences: {...preferences}})),
+
+ wanikaniPreferences: wanikaniDefaultPreferences,
+ updateWanikaniPreferences: (preferences) => set(() => ({wanikaniPreferences: {...preferences}})),
+
+ }),
+ {
+ name: 'user-preferences'
+ }));
\ No newline at end of file
diff --git a/src/util/InFlightRequestManager.js b/src/util/InFlightRequestManager.js
new file mode 100644
index 00000000..afb4d9c7
--- /dev/null
+++ b/src/util/InFlightRequestManager.js
@@ -0,0 +1,22 @@
+function InFlightRequestManager() {
+ let requests = {};
+
+ async function send(key, request) {
+ if (requests[key]) {
+ return await requests[key];
+ }
+ try {
+ let _request = request();
+ requests[key] = _request;
+ return await _request;
+ } finally {
+ delete request[key];
+ }
+ }
+
+ return {
+ send
+ };
+}
+
+export default InFlightRequestManager;
\ No newline at end of file
diff --git a/src/wanikani/components/WanikaniActiveItemChart.jsx b/src/wanikani/components/WanikaniActiveItemChart.jsx
index 17bd346d..94225ae5 100644
--- a/src/wanikani/components/WanikaniActiveItemChart.jsx
+++ b/src/wanikani/components/WanikaniActiveItemChart.jsx
@@ -5,6 +5,7 @@ import WanikaniItemTile from "./WanikaniItemTile.jsx";
import {combineAssignmentAndSubject, isSubjectHidden} from "../service/WanikaniDataUtil.js";
import {getColorByWanikaniSubjectType} from "../service/WanikaniStyleUtil.js";
import {WanikaniColors} from "../../Constants.js";
+import {useUserPreferences} from "../../hooks/useUserPreferences.jsx";
const defaultState = {
radicals: [],
@@ -124,22 +125,30 @@ function SubjectTile({subject}) {
}
function WanikaniLevelItemsChart({level}) {
+ const {wanikaniPreferences} = useUserPreferences();
const isFirstLoad = useRef(true);
const [isPreviousLevel, setIsPreviousLevel] = useState(true);
const [data, setData] = useState(defaultState);
useEffect(() => {
let isSubscribed = true;
+ const cleanUp = () => isSubscribed = false;
let _isFirstLoad = isFirstLoad.current
isFirstLoad.current = false;
+ if (_isFirstLoad && !wanikaniPreferences.showPreviousLevelByDefault) {
+ setIsPreviousLevel(false);
+ return cleanUp;
+ }
+
fetchData(level > 1 && isPreviousLevel ? level - 1 : level)
.then(d => {
if (!isSubscribed)
return;
- if (_isFirstLoad &&
+ if (wanikaniPreferences.showPreviousLevelByDefault &&
+ _isFirstLoad &&
d.radicalsStarted === d.radicals.length &&
d.kanjiStarted === d.kanji.length &&
d.vocabularyStarted === d.vocabulary.length) {
@@ -151,7 +160,7 @@ function WanikaniLevelItemsChart({level}) {
})
.catch(console.error);
- return () => isSubscribed = false;
+ return cleanUp;
}, [level, isPreviousLevel]);
return (
diff --git a/src/wanikani/service/WanikaniApiService.js b/src/wanikani/service/WanikaniApiService.js
index 5c86d90d..119f7dd4 100644
--- a/src/wanikani/service/WanikaniApiService.js
+++ b/src/wanikani/service/WanikaniApiService.js
@@ -1,17 +1,21 @@
import * as localForage from "localforage/dist/localforage"
import InMemoryCache from "../../util/InMemoryCache.js";
import {AppUrls} from "../../Constants.js";
+import InFlightRequestManager from "../../util/InFlightRequestManager.js";
const memoryCache = new InMemoryCache();
+const inFlightRequests = new InFlightRequestManager();
const wanikaniApiUrl = AppUrls.wanikaniApi;
const cacheKeys = {
apiKey: 'wanikani-api-key',
reviews: 'wanikani-reviews',
+ user: 'wanikani-user',
subjects: 'wanikani-subjects',
assignments: 'wanikani-assignments',
summary: 'wanikani-summary',
levelProgression: 'wanikani-level-progressions',
+ assignmentsForLevelPrefix: 'wanikani-assignment-for-level-'
}
const authHeader = (apiKey) => ({'Authorization': `Bearer ${apiKey}`})
@@ -21,16 +25,19 @@ function sleep(ms) {
}
async function fetchWithAutoRetry(input, init) {
- let response = await fetch(input, init);
-
- // Retry logic if rate limit is hit
- let attempts = 0;
- while (response.status == 429 && attempts < 10) {
- await sleep(10_000);
- response = await fetch(input, init);
- attempts += 1;
- }
- return response;
+ return await inFlightRequests
+ .send(input, async function () {
+ let response = await fetch(input, init);
+
+ // Retry logic if rate limit is hit
+ let attempts = 0;
+ while (response.status == 429 && attempts < 10) {
+ await sleep(10_000);
+ response = await fetch(input, init);
+ attempts += 1;
+ }
+ return response;
+ })
}
async function fetchWanikaniApi(path, apiKey, headers) {
@@ -51,18 +58,6 @@ async function fetchWanikaniApi(path, apiKey, headers) {
return fetchWithAutoRetry(`${wanikaniApiUrl}${path}`, options);
}
-async function getFromMemoryCacheOrFetch(path, _apiKey) {
- if (memoryCache.includes(path)) {
- return memoryCache.get(path);
- }
- const key = !!_apiKey ? _apiKey : apiKey();
- const response = await fetchWanikaniApi(path, key);
- const data = await response.json();
-
- memoryCache.put(path, data);
- return data;
-}
-
function apiKey() {
return localStorage.getItem(cacheKeys.apiKey)
}
@@ -75,19 +70,31 @@ function saveApiKey(key) {
}
}
-async function fetchMultiPageRequest(path, startingId) {
- const headers = {
- headers: {...authHeader(apiKey())},
+async function fetchMultiPageRequest(path, startingId, lastUpdatedTs) {
+ let options = {
+ headers: {
+ ...authHeader(apiKey()),
+ },
};
+ if (!!lastUpdatedTs) {
+ options.headers = {
+ ...options.headers,
+ ...ifModifiedSinceHeader(lastUpdatedTs)
+ };
+ }
+
const startingPageParam = !!startingId ? `?page_after_id=${startingId}` : '';
- const firstPageResponse = await fetchWithAutoRetry(`${wanikaniApiUrl}${path}${startingPageParam}`, headers);
+ const firstPageResponse = await fetchWithAutoRetry(`${wanikaniApiUrl}${path}${startingPageParam}`, options);
+ if (firstPageResponse.status === 304) {
+ return [];
+ }
const firstPage = await firstPageResponse.json();
let data = firstPage.data;
let nextPage = firstPage.pages['next_url']
while (!!nextPage) {
- let pageResponse = await fetchWithAutoRetry(nextPage, headers);
+ let pageResponse = await fetchWithAutoRetry(nextPage, options);
let page = await pageResponse.json();
data = data.concat(page.data);
nextPage = page.pages['next_url'];
@@ -134,23 +141,62 @@ async function flushCache() {
for (const key of Object.keys(cacheKeys)) {
await localForage.removeItem(cacheKeys[key]);
}
+
+ for (let i = 0; i < 60; i++) {
+ await localForage.removeItem(cacheKeys.assignmentsForLevelPrefix + (i + 1));
+ }
}
-async function getSummary() {
- const cachedValue = await localForage.getItem(cacheKeys.summary);
- if (!!cachedValue && cachedValue.lastUpdated > Date.now() - (1000 * 60 * 5)) {
+function ifModifiedSinceHeader(date) {
+ if (!date)
+ return null;
+ return {
+ 'If-Modified-Since': new Date(date).toUTCString()
+ };
+}
+
+async function unwrapResponse(response, fallbackValue) {
+ if (response.status === 304) {
+ return fallbackValue;
+ } else {
+ return await response.json();
+ }
+}
+
+async function fetchWithCache(path, cacheKey, ttl, _apiKey) {
+ const cachedValue = await localForage.getItem(cacheKey);
+ if (!!cachedValue && cachedValue.lastUpdated > Date.now() - ttl) {
return cachedValue.data;
}
- const response = await fetchWanikaniApi('/v2/summary', apiKey());
- const summary = await response.json();
+ const key = !!_apiKey ? _apiKey : apiKey();
+ const response = await fetchWanikaniApi(path, key,
+ ifModifiedSinceHeader(cachedValue?.lastUpdated));
+
+ const data = await unwrapResponse(response, cachedValue?.data);
- localForage.setItem(cacheKeys.summary, {
- data: summary,
+ localForage.setItem(cacheKey, {
+ data: data,
lastUpdated: new Date().getTime(),
});
- return summary;
+ return data;
+}
+
+async function getUser(apiKey) {
+ return await fetchWithCache('/v2/user', cacheKeys.user, 1000, apiKey)
+}
+
+async function getSummary() {
+ return await fetchWithCache('/v2/summary', cacheKeys.summary, 1000 * 60)
+}
+
+async function getAssignmentsForLevel(level) {
+ return await fetchWithCache(`/v2/assignments?levels=${level}`, cacheKeys.assignmentsForLevelPrefix + level, 1000 * 60)
+}
+
+async function getLevelProgress() {
+ return await fetchWithCache('/v2/level_progressions', cacheKeys.levelProgression, 1000 * 60)
}
export default {
@@ -160,29 +206,14 @@ export default {
login: async (apiKey) => {
- const user = await getFromMemoryCacheOrFetch('/v2/user', apiKey);
+ const user = await getUser(apiKey);
saveApiKey(apiKey);
return user;
},
- getUser: async () => getFromMemoryCacheOrFetch('/v2/user'),
+ getUser: getUser,
getSummary: getSummary,
- getLevelProgress: async () => {
- const cachedValue = await localForage.getItem(cacheKeys.levelProgression);
- if (!!cachedValue && cachedValue.lastUpdated > Date.now() - (1000 * 60 * 60)) {
- return cachedValue.data;
- }
-
- const response = await fetchWanikaniApi('/v2/level_progressions', apiKey());
- const data = await response.json();
-
- localForage.setItem(cacheKeys.levelProgression, {
- data: data,
- lastUpdated: new Date().getTime(),
- });
-
- return data;
- },
- getAssignmentsForLevel: (level) => getFromMemoryCacheOrFetch('/v2/assignments?levels=' + level),
+ getLevelProgress: getLevelProgress,
+ getAssignmentsForLevel: getAssignmentsForLevel,
getReviewStatistics: () => getFromMemoryCacheOrFetchMultiPageRequest('/v2/review_statistics'),
getAllAssignments: getAllAssignments,
getSubjects: async () => {
@@ -219,7 +250,7 @@ export default {
if (!!cachedValue) {
reviews = cachedValue.data;
const lastId = reviews[reviews.length - 1].id;
- const newData = await fetchMultiPageRequest('/v2/reviews', lastId);
+ const newData = await fetchMultiPageRequest('/v2/reviews', lastId, cachedValue.lastUpdated);
reviews.push(...newData);
} else {
reviews = await fetchMultiPageRequest('/v2/reviews');