Skip to content

Commit

Permalink
feat: add learning time trace
Browse files Browse the repository at this point in the history
  • Loading branch information
cuixiaorui committed Jul 24, 2024
1 parent 6587ab7 commit ab99692
Show file tree
Hide file tree
Showing 9 changed files with 525 additions and 28 deletions.
1 change: 1 addition & 0 deletions apps/client/api/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@ export async function fetchCurrentUser() {
...logtoUserInfo,
...extraInfo,
avatar: logtoUserInfo!.picture || "", // 添加 avatar 字段,默认值为 picture ( picture 这个属性不够清晰 不喜欢)
id: logtoUserInfo!.sub || "", // logto 把 user 唯一 id 叫做 sub , 不喜欢
} as User;
}
19 changes: 10 additions & 9 deletions apps/client/components/main/Game.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,29 +6,30 @@
<ModeChineseToEnglishMode />
</template>

<MainLearningTimer v-if="isAuthenticated()"></MainLearningTimer>
<MainTips />
<MainSummary />
<MainShare />
<MainAuthRequired />
<!-- TODO: 暂时先不提示(有些用户正在移动端的场景下使用)-->
<!-- <MainMessageBox
v-model:show-modal="isMessageShow"
cancel-btn-text="确定"
:content="messageContent"
/> -->
</template>

<script setup lang="ts">
import { onMounted } from "vue";
import { onMounted, onUnmounted } from "vue";
import { courseTimer } from "~/composables/courses/courseTimer";
// import { useDeviceTip } from "~/composables/main/game";
import { useLearningTimeTracker } from "~/composables/main/learningTimeTracker";
import { GameMode, useGameMode } from "~/composables/user/gameMode";
import { isAuthenticated } from "~/services/auth";
// const { isMessageShow, messageContent } = useDeviceTip();
const { currentGameMode } = useGameMode();
const { startTracking, stopTracking } = useLearningTimeTracker();
onMounted(() => {
courseTimer.reset();
startTracking();
});
onUnmounted(() => {
stopTracking();
});
</script>
82 changes: 82 additions & 0 deletions apps/client/components/main/LearningTimer.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<template>
<div class="flex items-center font-sans text-gray-300 dark:text-gray-500">
<div
ref="clockIcon"
class="mr-1 flex items-center justify-center"
>
<span
class="i-ph-alarm-bold"
style="width: 30px; height: 30px"
></span>
</div>
<p class="text-lg font-bold">{{ formattedTime }}</p>
</div>
</template>

<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import { useLearningTimeTracker } from "~/composables/main/learningTimeTracker";
const { $anime } = useNuxtApp();
const { totalSeconds, startTracking, stopTracking } = useLearningTimeTracker();
const clockIcon = ref(null);
const formattedTime = computed(() => {
const hours = Math.floor(totalSeconds.value / 3600);
const minutes = Math.floor((totalSeconds.value % 3600) / 60);
const seconds = totalSeconds.value % 60;
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
});
function animateClock() {
$anime({
targets: clockIcon.value,
translateY: [
{ value: -4, duration: 100, easing: "easeInOutQuad" },
{ value: 4, duration: 200, easing: "easeInOutQuad" },
{ value: -4, duration: 200, easing: "easeInOutQuad" },
{ value: 4, duration: 200, easing: "easeInOutQuad" },
{ value: 0, duration: 100, easing: "easeInOutQuad" },
],
rotate: [
{ value: -5, duration: 100, easing: "easeInOutQuad" },
{ value: 5, duration: 200, easing: "easeInOutQuad" },
{ value: -5, duration: 200, easing: "easeInOutQuad" },
{ value: 5, duration: 200, easing: "easeInOutQuad" },
{ value: 0, duration: 100, easing: "easeInOutQuad" },
],
scale: [
{ value: 1.1, duration: 400, easing: "easeInOutQuad" },
{ value: 1, duration: 400, easing: "easeInOutQuad" },
],
duration: 800,
loop: 1,
});
}
watch(totalSeconds, (newValue) => {
if (newValue % 60 === 0 && newValue !== 0) {
animateClock();
}
});
function handleVisibilityChange() {
if (document.hidden) {
stopTracking();
} else {
startTracking();
}
}
onMounted(() => {
document.addEventListener("visibilitychange", handleVisibilityChange);
window.addEventListener("beforeunload", stopTracking);
});
onUnmounted(() => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
window.removeEventListener("beforeunload", stopTracking);
});
</script>
24 changes: 24 additions & 0 deletions apps/client/components/main/Summary.vue
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@
)} `
}}
</p>
<p class="pl-14 text-base leading-loose text-gray-400">
今天一共学习 <span class="text-purple-500">{{ formattedMinutes }}分钟</span> 啦!
<span v-if="totalMinutes >= 30">太强了,给自己来点掌声 😄</span>
</p>
</div>
<div className="modal-action">
<button
Expand Down Expand Up @@ -88,6 +92,7 @@ import { useAuthRequire } from "~/composables/main/authRequire";
import { useConfetti } from "~/composables/main/confetti/useConfetti";
import { readOneSentencePerDayAloud } from "~/composables/main/englishSound";
import { useGameMode } from "~/composables/main/game";
import { useLearningTimeTracker } from "~/composables/main/learningTimeTracker";
import { useShareModal } from "~/composables/main/shareImage/share";
import { useDailySentence, useSummary } from "~/composables/main/summary";
import { useNavigation } from "~/composables/useNavigation";
Expand All @@ -109,6 +114,8 @@ const { confettiCanvasRef, playConfetti } = useConfetti();
const { showShareModal } = useShareModal();
const { updateActiveCourseMap } = useActiveCourseMap();
const { updateLearnRecord } = useLearnRecord();
const { stopTracking, startTracking } = useLearningTimeTracker();
const { totalMinutes, formattedMinutes } = useTotalLearningTime();
watch(showModal, (val) => {
if (val) {
Expand All @@ -123,6 +130,8 @@ watch(showModal, (val) => {
// 朗读每日一句
soundSentence();
// 延迟一小会放彩蛋
// 停止计时
stopTracking();
setTimeout(async () => {
playConfetti();
}, 300);
Expand All @@ -135,6 +144,20 @@ watch(showModal, (val) => {
}
});
function useTotalLearningTime() {
const { totalSeconds } = useLearningTimeTracker();
const totalMinutes = computed(() => Math.ceil(totalSeconds.value / 60));
const formattedMinutes = computed(() => {
return Math.max(totalMinutes.value, 1).toString();
});
return {
totalMinutes,
formattedMinutes,
};
}
function useDoAgain() {
const { showQuestion } = useGameMode();
Expand All @@ -143,6 +166,7 @@ function useDoAgain() {
hideSummary();
showQuestion();
courseTimer.reset();
startTracking();
}
return {
Expand Down
68 changes: 68 additions & 0 deletions apps/client/composables/main/learningTimeTracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { ref } from "vue";

import { useUserStore } from "~/store/user";

const totalSeconds = ref(0);
const isTracking = ref(false);
let timer: NodeJS.Timeout | null = null;

export function useLearningTimeTracker() {
const userStore = useUserStore();
const userId = userStore.user?.id;

function getStorageKey() {
const today = new Date().toISOString().split("T")[0];
return `learningTime_${userId}_${today}`;
}

function loadTime() {
const savedTime = localStorage.getItem(getStorageKey());
totalSeconds.value = savedTime ? parseInt(savedTime) : 0;
}

function saveTime() {
localStorage.setItem(getStorageKey(), totalSeconds.value.toString());
uploadTime();
}

function uploadTime() {
console.log("Uploading learning time:", {
userId,
date: new Date().toISOString().split("T")[0],
seconds: totalSeconds.value,
});
// Here we'll implement the actual API call later
}

function startTracking() {
if (isTracking.value) return;
loadTime();
if (totalSeconds.value === 0) {
saveTime(); // 如果是新的一天,立即创建新的存储项
}
isTracking.value = true;
timer = setInterval(() => {
totalSeconds.value++;
if (totalSeconds.value % 30 === 0) {
saveTime();
}
}, 1000);
}

function stopTracking() {
if (!isTracking.value) return;
if (timer) {
clearInterval(timer);
timer = null;
}
isTracking.value = false;
saveTime();
}

return {
totalSeconds,
isTracking,
startTracking,
stopTracking,
};
}
134 changes: 134 additions & 0 deletions apps/client/composables/main/tests/learningTimeTracker.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { nextTick } from "vue";

import { useLearningTimeTracker } from "../learningTimeTracker";

vi.mock("~/store/user", () => ({
useUserStore: vi.fn(() => ({
user: { id: "testUser" },
})),
}));

describe("useLearningTimeTracker", () => {
let tracker: ReturnType<typeof useLearningTimeTracker>;
tracker = useLearningTimeTracker();

beforeEach(() => {
// 清除 localStorage
localStorage.clear();
// 重置计时器
vi.useFakeTimers();
// 重置
tracker.totalSeconds.value = 0;
tracker.isTracking.value = false;
});

afterEach(() => {
vi.restoreAllMocks();
tracker.stopTracking();
});

it("should initialize with zero seconds and not tracking", () => {
expect(tracker.totalSeconds.value).toBe(0);
expect(tracker.isTracking.value).toBe(false);
});

it("should start tracking when startTracking is called", () => {
tracker.startTracking();
expect(tracker.isTracking.value).toBe(true);
});
[];
it("should stop tracking when stopTracking is called", () => {
tracker.startTracking();
tracker.stopTracking();
expect(tracker.isTracking.value).toBe(false);
});

it("should increment totalSeconds every second when tracking", async () => {
tracker.startTracking();
vi.advanceTimersByTime(3000);
await nextTick();
expect(tracker.totalSeconds.value).toBe(3);
});

it("should save time to localStorage every 30 seconds", () => {
const setItemSpy = vi.spyOn(Storage.prototype, "setItem");
tracker.startTracking();
vi.advanceTimersByTime(30000);
expect(setItemSpy).toHaveBeenCalledWith(expect.any(String), "30");
});

it("should load time from localStorage when starting tracking", () => {
const date = new Date().toISOString().split("T")[0];
localStorage.setItem(`learningTime_testUser_${date}`, "50");
tracker.startTracking();
expect(tracker.totalSeconds.value).toBe(50);
});

it("should not start tracking if already tracking", () => {
tracker.startTracking();
const initialSeconds = tracker.totalSeconds.value;
tracker.startTracking(); // 尝试再次启动
vi.advanceTimersByTime(1000);
expect(tracker.totalSeconds.value).toBe(initialSeconds + 1); // 只增加了1秒
});

it("should not stop tracking if not tracking", () => {
const consoleSpy = vi.spyOn(console, "log");
tracker.stopTracking(); // 尝试停止未启动的跟踪
expect(consoleSpy).not.toHaveBeenCalled(); // 确保没有调用 uploadTime
});

it("should upload time when stopping tracking", () => {
const consoleSpy = vi.spyOn(console, "log");
tracker.startTracking();
vi.advanceTimersByTime(5000);
tracker.stopTracking();
expect(consoleSpy).toHaveBeenCalledWith(
"Uploading learning time:",
expect.objectContaining({
userId: "testUser",
seconds: 5,
}),
);
});

it("should reset totalSeconds to zero when a new day starts", async () => {
// 模拟当前日期
const day1 = new Date("2023-07-23T12:00:00");
vi.setSystemTime(day1);

// 开始跟踪并设置一些初始时间
tracker.startTracking();
vi.advanceTimersByTime(5000); // 5 seconds
tracker.stopTracking();
expect(tracker.totalSeconds.value).toBe(5);

// 保存当前的存储键和值
const day1StorageKey = `learningTime_testUser_2023-07-23`;
const day1StoredValue = localStorage.getItem(day1StorageKey);

// 模拟时间跨越到下一天
const day2 = new Date("2023-07-24T12:00:00");
vi.setSystemTime(day2);

// 再次开始跟踪
tracker.startTracking();

// 检查 totalSeconds 是否重置为 0
expect(tracker.totalSeconds.value).toBe(0);

// 检查前一天的存储值是否保持不变
expect(localStorage.getItem(day1StorageKey)).toBe(day1StoredValue);

// 检查新的一天是否创建了新的存储项
const day2StorageKey = `learningTime_testUser_2023-07-24`;
expect(localStorage.getItem(day2StorageKey)).toBe("0");

// 添加一些时间后再次检查
vi.advanceTimersByTime(3000); // 3 seconds
tracker.stopTracking();
expect(tracker.totalSeconds.value).toBe(3);
expect(localStorage.getItem(day2StorageKey)).toBe("3");
});
});
Loading

0 comments on commit ab99692

Please sign in to comment.