diff --git a/apps/client/components/Home/CalendarGraph.vue b/apps/client/components/Home/CalendarGraph.vue index b029c9cf5..0badb4951 100644 --- a/apps/client/components/Home/CalendarGraph.vue +++ b/apps/client/components/Home/CalendarGraph.vue @@ -1,117 +1,184 @@ +const MIN_GRAPH_MARGIN = 12; - diff --git a/apps/client/components/Home/index.vue b/apps/client/components/Home/index.vue index 117e219d6..29c5c20a3 100644 --- a/apps/client/components/Home/index.vue +++ b/apps/client/components/Home/index.vue @@ -40,6 +40,7 @@ class="mt-10" :data="learnRecord.list" :totalCount="learnRecord.totalCount" + :yearOptions="yearOptions" @toggleYear="toggleYear" /> @@ -53,7 +54,7 @@ import { type CalendarData } from "~/composables/user/calendarGraph"; import { useUserStore } from "~/store/user"; const userStore = useUserStore(); -const { learnRecord, setupLearnRecord, setQueryYear } = useLearnRecord(); +const { learnRecord, setupLearnRecord, setQueryYear, yearOptions } = useLearnRecord(); const { toggleYear } = useCalendarGraph(); function useCalendarGraph() { diff --git a/apps/client/composables/learnRecord.ts b/apps/client/composables/learnRecord.ts index c4491abec..2e574e127 100644 --- a/apps/client/composables/learnRecord.ts +++ b/apps/client/composables/learnRecord.ts @@ -8,6 +8,9 @@ const learnRecord = ref({ list: [], totalCount: 0, }); + +const yearOptions = ref([2024, 2023, 2022]); + let isSetup = false; export function useLearnRecord() { @@ -39,6 +42,7 @@ export function useLearnRecord() { return { learnRecord, + yearOptions, updateLearnRecord, setQueryYear, setupLearnRecord, diff --git a/apps/client/composables/user/calendarGraph.ts b/apps/client/composables/user/calendarGraph.ts index fa6a8e6e3..fec444e14 100644 --- a/apps/client/composables/user/calendarGraph.ts +++ b/apps/client/composables/user/calendarGraph.ts @@ -1,233 +1,341 @@ import dayjs from "dayjs"; import { ref } from "vue"; -const weeks: Record = { - 0: "Sun", - 1: "Mon", - 2: "Tue", - 3: "Wed", - 4: "Thu", - 5: "Fri", - 6: "Sat", -}; -const weeksZh: Record = { - 0: "周日", - 1: "周一", - 2: "周二", - 3: "周三", - 4: "周四", - 5: "周五", - 6: "周六", -}; -const months: Record = { - 0: "January", - 1: "February", - 2: "March", - 3: "April", - 4: "May", - 5: "June", - 6: "July", - 7: "August", - 8: "September", - 9: "October", - 10: "November", - 11: "December", -}; -const monthsZh: Record = { - 0: "一月", - 1: "二月", - 2: "三月", - 3: "四月", - 4: "五月", - 5: "六月", - 6: "七月", - 7: "八月", - 8: "九月", - 9: "十月", - 10: "十一月", - 11: "十二月", -}; - -export interface EmitsType { - (event: "toggleYear", year?: number): void; +import { Theme } from "~/composables/darkMode"; + +enum CalendarLevel { + NONE = 0, + LOW = 1, + MEDIUM = 2, + HIGH = 3, + DIE = 4, // 😂 +} + +export enum Locale { + ZH_CN = "zh-CN", + EN_US = "en-US", +} + +export interface CalendarBlock { + date: string; + count: number; + tip?: string; + bgColor?: string; } export interface CalendarData { - /** YYYY-MM-DD */ day: string; count: number; } -interface Options { - label: string; - value: number; +interface Other { + less: string; + more: string; + summaryFn: (count: number) => string; } -interface TableHead { - colSpan: number; - month: string; -} -interface TableBody { - date: Date; - tips?: string; - bg?: string; + +interface CalendarOptions { + data: CalendarData[]; + year?: number; + locale?: Locale; + theme?: Theme; + beginDay?: "sunday" | "monday"; + separate?: boolean | "odd" | "even"; + formatFn?: (date: string, count: number) => string; } -const year = ref(); -const yearOptions = ref([]); -const thead = ref([]); -const tbody = ref<(null | TableBody)[][]>([]); +class CalendarGraph { + // 可用的图例颜色 + static readonly LEGENDS: Record = { + light: ["#ebedf0", "#9be9a8", "#40c463", "#30a14e", "#216e39"], + dark: ["#161b22", "#0e4429", "#006d32", "#26a641", "#39d353"], + }; + + // 星期的语言配置 + static readonly WEEKS: Record = { + "zh-CN": ["周日", "周一", "周二", "周三", "周四", "周五", "周六"], + "en-US": ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], + }; + + // 月份的语言配置 + static readonly MONTHS: Record = { + "zh-CN": [ + "一月", + "二月", + "三月", + "四月", + "五月", + "六月", + "七月", + "八月", + "九月", + "十月", + "十一月", + "十二月", + ], + "en-US": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], + }; + + // 提示的语言配置 + static readonly TOOLTIPS = { + "zh-CN": (date: string, count: number) => { + const pre = count > 0 ? `${count} 次学习` : "没有学习"; + return `${pre}, ${date}`; + }, + "en-US": (date: string, count: number) => { + const pre = count > 0 ? `${count} times` : "No learning"; + const month = this.MONTHS["en-US"][dayjs(date).month()]; + return `${pre}, ${month} ${this._getOrdinalSuffix(date)}.`; + }, + }; -export function useCalendarGraph(emits: EmitsType) { - getOptions(); + // 其他标注的语言配置 + static readonly OTHERS = { + "zh-CN": { + less: "更少", + more: "更多", + summaryFn: (count: number) => `一共学习了 ${count} 次`, + }, + "en-US": { + less: "Less", + more: "More", + summaryFn: (count: number) => `Total ${count} times`, + }, + }; + + private _data: CalendarData[] = []; + private _year: number; + private _locale: Locale; + private _theme: Theme; + private tooltipFn: Function; + private _beginDay: string; + private _separate: boolean | "odd" | "even"; + + constructor(options: CalendarOptions) { + this._data = options.data; + this._locale = options.locale || Locale.ZH_CN; + this._theme = options.theme || Theme.LIGHT; + this._year = options.year || dayjs().year(); + this.tooltipFn = options.formatFn || CalendarGraph.TOOLTIPS[Locale.ZH_CN]; + this._beginDay = options.beginDay || "sunday"; + this._separate = options.separate || false; + } /** - * format date - * @param date date - * @returns YYYY-MM-DD + * @description 辅助函数 + * 获取英文日期的序数后缀,用于提示 + * @param date 日期 + * @returns 序数后缀 1st, 2nd, 3rd, 4th */ - function format(date: Date) { - return date.toISOString().slice(0, 10); + static _getOrdinalSuffix(date: number | Date | string) { + const day = dayjs(date).date(); + const suffix = ["th", "st", "nd", "rd"]; + const v = day % 100; + return day + (suffix[(v - 20) % 10] || suffix[v] || suffix[0]); } - function getOptions() { - // 重置列表,避免点击其他页面回来的时候录入重复的年份数据 - yearOptions.value = []; - for (let i = 2024; i <= new Date().getFullYear(); i++) { - yearOptions.value.unshift({ label: i.toString(), value: i }); + /** + * @description 辅助函数 + * 根据学习数量获取level + * @param count 数量 + * @returns level + */ + private _getLevel(count: number) { + if (count === 0) { + return CalendarLevel.NONE; + } else if (count <= 3) { + return CalendarLevel.LOW; + } else if (count <= 5) { + return CalendarLevel.MEDIUM; + } else if (count <= 10) { + return CalendarLevel.HIGH; + } else { + return CalendarLevel.DIE; } } - function getOrdinalSuffix(day: number) { - const lastTwoDigits = day % 100; - if ([11, 12, 13].includes(lastTwoDigits)) return "th"; - const lastDigit = day % 10; - if ([1, 2, 3].includes(lastDigit)) return { 1: "st", 2: "nd", 3: "rd" }[lastDigit] as string; - return "th"; + /** + * @description 辅助函数 + * 获取level对应的颜色 + * @param count 数量 + * @returns 颜色 + */ + private _getLevelColor(count: number) { + const level = this._getLevel(count); + return CalendarGraph.LEGENDS[this._theme][level]; } - function getActivityLevel(count?: number) { - if (!count) return ""; - if (count < 3) return "low"; - if (count < 5) return "moderate"; - if (count < 10) return "high"; - return "higher"; + /** + * @description 辅助函数 + * 获取日历的日期范围 + * @returns 日期范围 [startDate, endDate] + */ + private _getDateRange() { + // 如果有年份,返回当年的第一天和最后一天 + if (this._year !== dayjs().year()) { + return [dayjs(`${this._year}-01-01`), dayjs(`${this._year}-12-31`)]; + } + + // 最后一天是今天,往前推一年 + const endDate = dayjs(); + const startDate = endDate.subtract(1, "year"); + + return [startDate, endDate]; } - function renderBody(list: CalendarData[]) { - return tbody.value.map((row) => { - return row.map((item) => { - if (!item) return null; + /** + * 获取星期的标签 + * @param separate 是否分隔 + * @param odd 只显示奇数星期 + * @returns 星期标签 + */ + getWeeksLabels() { + let week = [...CalendarGraph.WEEKS[this._locale], ...CalendarGraph.WEEKS[this._locale]]; - const year = item.date.getFullYear(); - const month = String(item.date.getMonth() + 1).padStart(2, "0"); - const day = String(item.date.getDate()).padStart(2, "0"); - const date = dayjs(item.date).format("YYYY-MM-DD"); + let result = week; + switch (this._separate) { + case "odd": + case true: + result = week.map((v, i) => (i % 2 === 1 ? v : "")); + break; - const current = list.find((f) => f.day === date); + case "even": + result = week.map((v, i) => (i % 2 === 0 ? v : "")); + break; + } - const tipText = current?.count ? `${current?.count}次学习` : `没有学习`; - const tips = `${tipText}, ${year}-${month}-${day}`; + if (this._beginDay === "monday") { + return result.slice(1, 8); + } - return { date: item.date, tips, bg: getActivityLevel(current?.count) }; - }); - }); + return result.slice(0, 7); } - function renderHead(thead: { offset: number; month: number }[]) { - return thead.map((item, i) => { - const nextItem = thead[i + 1] || { offset: 53 }; - const colSpan = nextItem.offset - item.offset; - const month = monthsZh[item.month]?.slice(0, 3); - return { colSpan, month }; - }); + /** + * 获取月份的标签, 默认返回12个月的标签 + * @returns 月份标签 + */ + getMonthsLabels() { + const month = [...CalendarGraph.MONTHS[this._locale], ...CalendarGraph.MONTHS[this._locale]]; + const [startDate] = this._getDateRange(); + const startMonth = startDate.month(); + const count = this._year === dayjs().year() ? 13 : 12; + return month.slice(startMonth, startMonth + count); } - function initTable(value?: number) { - emits("toggleYear", value); - year.value = value; - const data = initData(value); - thead.value = renderHead(data.thead); - tbody.value = data.tbody; + /** + * 获取图例 + * @returns 图例颜色数组 + */ + getLegends() { + return CalendarGraph.LEGENDS[this._theme]; } - function calcStartDate(date: Date = new Date()) { - const offset = 52 * 7 + (date.getDay() % 7); - const startDay = date.getDate() - offset; - return new Date(date.setDate(startDay)); + /** + * 获取提示 + * @returns 提示数据对象 + */ + getOthers() { + return CalendarGraph.OTHERS[this._locale]; } - function calcDateRange(year?: number) { - const startDate = year ? new Date(`${year}-01-01`) : calcStartDate(new Date()); - const endDate = year ? new Date(`${year}-12-31`) : new Date(); - return { startDate, endDate }; + /** + * 重新设置属性 + * @param options 选项 + */ + setOptions(options: CalendarOptions) { + this._data = options.data || this._data; + this._locale = options.locale || this._locale; + this._theme = options.theme || this._theme; + this._year = options.year || this._year; + this.tooltipFn = options.formatFn || CalendarGraph.TOOLTIPS[options.locale || Locale.ZH_CN]; + this._beginDay = options.beginDay || this._beginDay; + this._separate = options.separate || this._separate; } - function initTbody(startDate: Date) { - const tbody: (null | TableBody)[][] = [[], [], [], [], [], [], []]; - const week = startDate.getDay(); - for (let i = 0; i < week; i++) { - tbody[i].push(null); + /** + * 生成日历数据 + * @returns 日历数据 + */ + generateCalendarData() { + const result: CalendarBlock[] = []; + const [startDate, endDate] = this._getDateRange(); + + // 如果开始日期不是我规定的一周的第一天,那么需要补充前面的日期 + const day = startDate.day(); + let diff = day; + if (this._beginDay === "monday" && day !== 1) { + diff = day === 0 ? 6 : day - 1; } - return tbody; + + const newStartDate = startDate.subtract(diff, "day"); + const total = endDate.diff(startDate, "day") + 1 + diff; + + for (let i = 0; i < total; i++) { + const date = newStartDate.add(i, "day").format("YYYY-MM-DD"); + const count = this._data.find((v) => v.day === date)?.count || 0; + const tip = this.tooltipFn(date, count); + const bgColor = this._getLevelColor(count); + + result.push({ date, count, tip, bgColor }); + } + + return result; } +} + +export function useCalendarGraph() { + const calendarGraph = new CalendarGraph({ data: [] }); + + const renderData = ref([]); + const renderWeekLabels = ref([]); + const renderMonthLabels = ref<{ label: string; offset: number }[]>([]); + const renderLegends = ref([]); + const renderTips = ref(); - function initData(year?: number) { - const { startDate, endDate } = calcDateRange(year); - - const tbody: (null | TableBody)[][] = initTbody(startDate); - const thead: { offset: number; month: number }[] = []; - - let theadLen = 12; - let nextDate = new Date(+startDate); - while (nextDate <= endDate) { - const month = nextDate.getMonth(); - const week = nextDate.getDay(); - const day = nextDate.getDate(); - - if (day === 1 && thead.length < theadLen) { - const rowIndex = week; - const preRowIndex = rowIndex - 1; - const colIndex = tbody[rowIndex].length; - const nonCurrentMonthDate = tbody[preRowIndex] && tbody[preRowIndex][colIndex] !== null; - const offset = nonCurrentMonthDate ? colIndex + 1 : colIndex; - - const isFirstTh = thead.length === 0; - if (isFirstTh && offset !== 0) { - const preTH = { offset: 0, month: (month || 12) - 1 }; - if (offset < 3) { - preTH.month = -1; - theadLen = 13; - } - thead.push(preTH); - } - - thead.push({ offset, month }); + const _calcHeaderOffset = (labels: string[]) => { + // 需要计算每个月1号所在的列 + const offsets: number[] = []; + renderData.value.map((item, index) => { + if (item.date.endsWith("-01")) { + const offset = Math.floor(index / 7); + offsets.push(offset); } + }); - tbody[week].push({ date: new Date(+nextDate) }); + // 总列数 + const total = Math.ceil(renderData.value.length / 7); - nextDate.setDate(day + 1); + const result = []; + // 月份标签从后往前添加 offset + for (let i = total - 1; i >= 0; i--) { + const index = offsets.indexOf(i); + result.push({ + label: index !== -1 ? labels.pop() || "" : "", + offset: i, + }); } - return { thead, tbody }; - } + return result.reverse(); + }; + + const reRender = (options: CalendarOptions) => { + calendarGraph.setOptions(options); + + const monthsLabels = calendarGraph.getMonthsLabels(); + renderData.value = calendarGraph.generateCalendarData(); + renderWeekLabels.value = calendarGraph.getWeeksLabels(); + renderMonthLabels.value = _calcHeaderOffset(monthsLabels); + renderLegends.value = calendarGraph.getLegends(); + renderTips.value = calendarGraph.getOthers(); + }; return { - format, - calcStartDate, - calcDateRange, - getActivityLevel, - getOrdinalSuffix, - initTable, - initTbody, - initData, - renderHead, - renderBody, - weeks, - weeksZh, - thead, - tbody, - year, - yearOptions, + reRender, + renderData, + renderWeekLabels, + renderMonthLabels, + renderLegends, + renderTips, }; } diff --git a/apps/client/composables/user/tests/calendarGraph.spec.ts b/apps/client/composables/user/tests/calendarGraph.spec.ts index 53fb2a5eb..f07569af9 100644 --- a/apps/client/composables/user/tests/calendarGraph.spec.ts +++ b/apps/client/composables/user/tests/calendarGraph.spec.ts @@ -1,136 +1,17 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; -import type { EmitsType } from "../calendarGraph"; import { useCalendarGraph } from "../calendarGraph"; describe("use calendar graph", () => { - const emits: EmitsType = vi.fn(); - const { - format, - getOrdinalSuffix, - getActivityLevel, - calcStartDate, - calcDateRange, - initTable, - initTbody, - initData, - renderHead, - renderBody, - } = useCalendarGraph(emits); + const { reRender, renderData, renderLegends, renderMonthLabels, renderTips, renderWeekLabels } = + useCalendarGraph(); - it("should return the formatted date", () => { - const date = format(new Date("2024-01-01")); + function checkRenderData(date: string, count: number) { + const item = renderData.value.find((item) => item.date === date); + return count === item?.count; + } - expect(date).toBe("2024-01-01"); - }); - - it.each([ - [1, "st"], - [2, "nd"], - [3, "rd"], - [4, "th"], - [11, "th"], - [12, "th"], - [13, "th"], - [21, "st"], - [22, "nd"], - [23, "rd"], - [31, "st"], - ])("%s should return the suffix %s", (day, expected) => { - expect(getOrdinalSuffix(day)).toBe(expected); - }); - - it.each([ - [1, "low"], - [3, "moderate"], - [5, "high"], - [10, "higher"], - ])("%s should return the activity level %s", (day, expected) => { - expect(getActivityLevel(day)).toBe(expected); - }); - - it("should return the start date for graph", () => { - const date = calcStartDate(new Date("2024-03-11")); - - expect(format(date)).toBe("2023-03-12"); - }); - - it("should return the date range for 2024", () => { - const { startDate, endDate } = calcDateRange(2024); - - expect(format(startDate)).toBe("2024-01-01"); - expect(format(endDate)).toBe("2024-12-31"); - }); - - it("should return the date range for today to last year", () => { - const start = calcStartDate(new Date()); - const end = new Date(); - - const { startDate, endDate } = calcDateRange(); - - expect(format(startDate)).toBe(format(start)); - expect(format(endDate)).toBe(format(end)); - }); - - it("should return initialized table body data", () => { - const sundayStart = initTbody(new Date("2023-01-01")); // sunday - const wednesdayStart = initTbody(new Date("2020-01-01")); // wednesday - - expect(sundayStart).toEqual([[], [], [], [], [], [], []]); - expect(wednesdayStart).toEqual([[null], [null], [null], [], [], [], []]); - }); - - it("should return initialized table data", () => { - const { thead, tbody } = initData(2024); - - expect(thead).toEqual([ - { offset: 0, month: 0 }, - { offset: 5, month: 1 }, - { offset: 9, month: 2 }, - { offset: 14, month: 3 }, - { offset: 18, month: 4 }, - { offset: 22, month: 5 }, - { offset: 27, month: 6 }, - { offset: 31, month: 7 }, - { offset: 35, month: 8 }, - { offset: 40, month: 9 }, - { offset: 44, month: 10 }, - { offset: 48, month: 11 }, - ]); - expect(tbody[0].length).toBe(53); - expect(format(tbody[1][0]!.date)).toBe("2024-01-01"); - }); - - it("should initial table", () => { - // - initTable(2024); - - expect(emits).toHaveBeenCalledWith("toggleYear", 2024); - }); - - it("should return render table header data", () => { - const { thead } = initData(2024); - - const data = renderHead(thead); - - expect(data).toEqual([ - { colSpan: 5, month: "一月" }, - { colSpan: 4, month: "二月" }, - { colSpan: 5, month: "三月" }, - { colSpan: 4, month: "四月" }, - { colSpan: 4, month: "五月" }, - { colSpan: 5, month: "六月" }, - { colSpan: 4, month: "七月" }, - { colSpan: 4, month: "八月" }, - { colSpan: 5, month: "九月" }, - { colSpan: 4, month: "十月" }, - { colSpan: 4, month: "十一月" }, - { colSpan: 5, month: "十二月" }, - ]); - }); - - it("should return render table body data", () => { - initTable(2024); + it("should get calendar render data", () => { const apiData = [ { day: "2024-01-01", count: 1 }, { day: "2024-01-02", count: 3 }, @@ -138,15 +19,10 @@ describe("use calendar graph", () => { { day: "2024-01-04", count: 10 }, ]; - const tbody = renderBody(apiData); + reRender({ data: apiData }); - expect(tbody[1][0]?.tips).toBe("1次学习, 2024-01-01"); - expect(tbody[1][0]?.bg).toBe("low"); - expect(tbody[2][0]?.tips).toBe("3次学习, 2024-01-02"); - expect(tbody[2][0]?.bg).toBe("moderate"); - expect(tbody[3][0]?.tips).toBe("5次学习, 2024-01-03"); - expect(tbody[3][0]?.bg).toBe("high"); - expect(tbody[4][0]?.tips).toBe("10次学习, 2024-01-04"); - expect(tbody[4][0]?.bg).toBe("higher"); + apiData.forEach((item) => { + expect(checkRenderData(item.day, item.count)).true; + }); }); });