feat: RoutineAchivementCalendar에서 루틴별로 성과를 볼 수 있는 기능을 완성

This commit is contained in:
sechan100 2025-01-30 17:47:37 +09:00
parent 1a289a9b6f
commit f2640fa5cb
11 changed files with 284 additions and 105 deletions

View file

@ -26,7 +26,7 @@ export interface AchivementPageProps {
}
export const AchivementPage = ({ month }: AchivementPageProps) => {
// FIXME: routine -> note로
const [type, setType] = useState<AchivementType>("routine");
const [type, setType] = useState<AchivementType>("note");
const { view, leafBgColor } = useLeaf();
return (
@ -66,9 +66,9 @@ export const AchivementPage = ({ month }: AchivementPageProps) => {
}}>
<div>
{type === "note" ?
<NoteAchivementWidget month={month} height={360} maxWidth={420} />
<NoteAchivementWidget month={month} height={400} maxWidth={420} />
:
<RoutineAchivementWidget month={month} height={360} maxWidth={420} routineName={"🛏️ 이부자리 정리하기"} />
<RoutineAchivementWidget month={month} height={400} maxWidth={420} />
}
</div>
</div>

View file

@ -31,25 +31,31 @@ interface Props {
children: (month: Month, index?: number) => React.ReactNode;
verticalHeight?: number;
maxWidth?: number
onMonthChange?: (month: Month) => void;
}
export const SwipeableCalendar = ({
month: propsMonth,
children,
verticalHeight,
maxWidth,
onMonthChange
}: Props) => {
const [months, setMonths] = useState<Month[]>(loadMonths(propsMonth));
const [activeMonth, setActiveMonth] = useState<Month>(propsMonth);
const resetMonths = useCallback((month: Month) => {
setActiveMonth(month);
setMonths(loadMonths(month));
}, []);
const onSlideChange = useCallback((month: Month) => {
setActiveMonth(month);
}, []);
onMonthChange?.(month);
}, [onMonthChange]);
const resetMonths = useCallback((month: Month) => {
setMonths(loadMonths(month));
onSlideChange(month);
}, [onSlideChange]);
return (
<div
className="dr-swipeable-calendar"

View file

@ -0,0 +1,43 @@
import { extend } from "lodash";
import { useCallback, useEffect, useRef } from "react"
/**
* ref를 , ref가 .
* , .
*/
export const useChildRef = <P extends HTMLElement, C extends HTMLElement>(tagName: keyof HTMLElementTagNameMap) => {
const parentRef = useRef<P>(null);
const childRef = useRef<C | null>(null);
// 최초 1회 초기화 실행
useEffect(() => {
create();
return destroy;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const destroy = useCallback(() => {
if(!parentRef.current || !childRef.current) return;
parentRef.current.removeChild(childRef.current);
childRef.current = null;
}, []);
/**
* .
*/
const create = useCallback(() => {
if(!parentRef.current || childRef.current) return;
const child = document.createElement(tagName);
parentRef.current.appendChild(child);
childRef.current = child as C;
}, [tagName]);
return {
parentRef,
childRef,
create,
destroy
}
}

View file

@ -2,6 +2,8 @@ import { Day } from "./day";
export type MonthFormat = string;
export class Month {
readonly #startDay: Day;
readonly #endDay: Day;
@ -45,6 +47,10 @@ export class Month {
return new Month(
this.#startDay.clone(m => m.subtract(amount, "month"))
);
}
format(): MonthFormat {
return this.#startDay.format("YYYY-MM");
}
}

View file

@ -30,7 +30,7 @@ type ExtractState<S> = S extends {
getState: () => infer T;
} ? T : never;
type ReadonlyStoreApi<T> = Pick<StoreApi<T>, 'getState' | 'getInitialState' | 'subscribe'>;
export type UseBoundStore<S extends ReadonlyStoreApi<unknown>> = {
type UseBoundStore<S extends ReadonlyStoreApi<unknown>> = {
(): ExtractState<S>;
<U>(selector: (state: ExtractState<S>) => U): U;
} & S;

View file

@ -1,43 +0,0 @@
import { NoteEntity, noteRepository, RoutineNote } from "@entities/note";
import { Tile, TileMap } from "./types";
import { Month } from "@shared/period/month";
import { DayFormat } from "@shared/period/day";
/**
*
* @param routineName
* @param month
*/
export const resolveRoutineTiles = async (routineName: string, month: Month): Promise<TileMap> => {
const notes = await noteRepository.loadBetween(month.startDay, month.endDay);
const tileMap = new Map<DayFormat, Tile>();
const end = month.startDay.daysInMonth();
for(let d = 1; d <= end; d++) {
const day = month.startDay.clone(m => m.add(d-1, "day"));
const note = notes.find(note => note.day.isSameDay(day));
let tile: Tile | null = null;
// 노트가 존재
if(note){
const routineTask = NoteEntity.findTask(note, routineName);
// 노트에 routine이 존재
if(routineTask){
tile = {
day,
state: routineTask.state
}
}
}
if(!tile){
tile = {
day,
state: "inactive"
}
}
tileMap.set(day.format(), tile);
}
return tileMap;
}

View file

@ -0,0 +1,23 @@
import { MonthFormat } from "@shared/period/month";
import { create } from "zustand";
type UseRoutineSelector = {
currentRoutine: string;
setCurrentRoutine: (routine: string) => void;
routineOptionsPerMonth: ReadonlyMap<MonthFormat, string[]>;
addRoutineOptionsPerMonth: (monthFormat: MonthFormat, routineOptions: string[]) => void;
}
export const useRoutineSelector = create<UseRoutineSelector>((set, get) => ({
currentRoutine: "",
setCurrentRoutine: (routine: string) => set({ currentRoutine: routine }),
routineOptionsPerMonth: new Map(),
addRoutineOptionsPerMonth: (monthFormat: MonthFormat, routineOptions: string[]) => {
const map = get().routineOptionsPerMonth as Map<MonthFormat, string[]>;
if(!map.has(monthFormat)){
map.set(monthFormat, routineOptions);
set({ routineOptionsPerMonth: new Map(map) });
}
}
}))

View file

@ -1,57 +1,118 @@
/** @jsxImportSource @emotion/react */
import { noteRepository } from "@entities/note";
import { isRoutineTask, NoteEntity, noteRepository } from "@entities/note";
import { BaseCalendar } from "@shared/components/BaseCalendar";
import { Day } from "@shared/period/day";
import { Day, DayFormat } from "@shared/period/day";
import { Month } from "@shared/period/month";
import { useAsync } from "@shared/utils/use-async";
import { useCallback } from "react";
import { CalendarTile } from "./CalendarTile";
import { useCallback, useEffect, useMemo } from "react";
import { Tile } from "../model/types";
import { resolveRoutineTiles } from "../model/resolve-routine-tiles";
import { useRoutineSelector } from "../model/use-routine-selector";
import { CalendarTile } from "./CalendarTile";
interface Props {
month: Month;
routineName: string;
}
export const CalendarSlide = ({ routineName, month }: Props) => {
const tilesAsync = useAsync(async () => await resolveRoutineTiles(routineName, month), [month]);
export const CalendarSlide = ({
month,
}: Props) => {
const notesAsync = useAsync(() => noteRepository.loadBetween(month.startDay, month.endDay), [month]);
const { currentRoutine, addRoutineOptionsPerMonth, routineOptionsPerMonth } = useRoutineSelector();
const tileMap = useMemo<Map<DayFormat, Tile> | null>(() => {
if(notesAsync.loading || !notesAsync.value) return null;
const notes = notesAsync.value;
const map = new Map<DayFormat, Tile>();
const end = month.startDay.daysInMonth();
for(let d = 1; d <= end; d++) {
const day = month.startDay.clone(m => m.add(d-1, "day"));
const note = notes.find(note => note.day.isSameDay(day));
let tile: Tile | null = null;
// 노트가 존재
if(note){
const routineTask = NoteEntity.findTask(note, currentRoutine);
// 노트에 routine이 존재
if(routineTask){
tile = {
day,
state: routineTask.state
}
}
}
if(!tile){
tile = {
day,
state: "inactive"
}
}
map.set(day.format(), tile);
}
return map;
}, [currentRoutine, month, notesAsync.loading, notesAsync.value]);
// routineOptions를 최초 계산하여 routineOptionsPerMonth에 추가한다.
useEffect(() => {
if(notesAsync.loading || !notesAsync.value) return;
if(routineOptionsPerMonth.has(month.format())) return;
const notes = notesAsync.value;
const existingRoutineNames = notes.flatMap(note => NoteEntity
.flatten(note)
.filter(isRoutineTask)
.map(routine => routine.name)
);
const routineOptions = Array.from(new Set(existingRoutineNames));
addRoutineOptionsPerMonth(month.format(), routineOptions);
}, [addRoutineOptionsPerMonth, month, notesAsync.loading, notesAsync.value, routineOptionsPerMonth]);
const tile = useCallback((day: Day) => {
if(day.month !== month.monthNum) return <></>;
const tile = tilesAsync.value?.get(day.format());
if(!tileMap) return <></>;
const tile = tileMap.get(day.format());
if(!tile) throw new Error(`tile not found for ${day.format()}`);
return <CalendarTile tile={tile} />;
}, [month.monthNum, tilesAsync.value]);
}, [month.monthNum, tileMap]);
const onTileClick = useCallback((day: Day) => {
console.log(day);
}, []);
const tileDisabled = useCallback((day: Day) => {
return day.month !== month.monthNum;
}, [month]);
if(tilesAsync.loading) return <div>Loading...</div>;
if(!tileMap) return <></>;
return (
<BaseCalendar
month={month}
setMonth={() => {}}
onTileClick={onTileClick}
tile={tile}
showNavigation={false}
tileDisabled={tileDisabled}
styleOptions={{
tileContainer: {
gap: "0",
},
tile: {
border: "0",
overflow: "visible !important",
}
}}
/>
<>
<BaseCalendar
month={month}
setMonth={() => {}}
onTileClick={onTileClick}
tile={tile}
showNavigation={false}
tileDisabled={tileDisabled}
styleOptions={{
tileContainer: {
gap: "0",
},
tile: {
border: "0",
overflow: "visible !important",
}
}}
/>
</>
);
};

View file

@ -2,16 +2,11 @@
import { TaskCheckbox } from "@features/task-el";
import { Tile } from "../model/types"
import { Circle } from "@shared/components/Circle";
import { getCustomAccentHSL } from "@shared/components/obsidian-accent-color";
import { Day } from "@shared/period/day";
import { Badge } from "@mui/material";
type Props = {
tile: Tile;
}

View file

@ -1,43 +1,35 @@
/** @jsxImportSource @emotion/react */
import { Month } from '@shared/period/month';
import { SwipeableCalendar } from '@shared/components/swipeable-calender/SwipeableCalendar';
import { Month } from '@shared/period/month';
import { useState } from 'react';
import { CalendarSlide } from './CalendarSlide';
import { TEXT_CSS } from '@shared/components/text-style';
import { useLeaf } from '@shared/view/use-leaf';
import { RoutineSelector } from './RoutineSelector';
export interface Props {
month: Month;
routineName: string;
height: number;
maxWidth: number;
}
export const RoutineAchivementWidget = ({
month,
month: propsMonth,
height,
routineName,
maxWidth
}: Props) => {
const { view, leafBgColor } = useLeaf();
const [month, setMonth] = useState<Month>(propsMonth);
return (
<div>
<div css={[TEXT_CSS.medium, {
padding: "1em 1em",
}]}>
{routineName}
</div>
<>
<RoutineSelector month={month} maxWidth={maxWidth} />
<SwipeableCalendar
month={month}
verticalHeight={height}
maxWidth={maxWidth}
onMonthChange={setMonth}
>
{month => <CalendarSlide month={month} routineName={routineName} />}
{month => <CalendarSlide month={month} />}
</SwipeableCalendar>
<div>
</div>
</div>
</>
)
}

View file

@ -0,0 +1,96 @@
/** @jsxImportSource @emotion/react */
import { Button } from '@shared/components/Button';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import { useRoutineSelector } from "../model/use-routine-selector";
import { useCallback, useMemo, useState } from "react";
import { useLeaf } from '@shared/view/use-leaf';
import { maxWidth } from '@mui/system';
import { Month } from '@shared/period/month';
type Props = {
month: Month;
className?: string;
maxWidth?: number;
}
export const RoutineSelector = ({
month,
maxWidth,
className
}: Props) => {
const { currentRoutine, setCurrentRoutine, routineOptionsPerMonth } = useRoutineSelector();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const { view } = useLeaf();
const handleRoutineOptionClick = useCallback((routine: string) => {
setCurrentRoutine(routine);
// menu close
setAnchorEl(null);
}, [setCurrentRoutine]);
return (
<div>
<Button
css={{
width: view.contentEl.clientWidth,
maxWidth: maxWidth,
height: "50px",
border: "1px solid var(--color-base-30)",
}}
id="dr-routine-achivement_routine-selector"
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
}}
>
{currentRoutine !== "" ? currentRoutine : "Select Routine"}
</Button>
<Menu
anchorEl={anchorEl}
open={open}
onClose={() => setAnchorEl(null)}
css={{
".MuiPaper-root":{
width: view.contentEl.clientWidth,
transform: "translateX(16px) !important",
maxHeight: "500px",
},
"ul": {
"li":{
minHeight: "20px !important",
borderTop: "1px solid var(--color-base-30)",
},
},
"li:nth-child(1)": {
borderTop: "none"
}
}}
>
{(() => {
const options = routineOptionsPerMonth.get(month.format());
if(!options || options.length === 0) return <MenuItem>no routines...</MenuItem>;
return options.map(routine => (
<MenuItem
key={routine}
onClick={() => handleRoutineOptionClick(routine)}
>
{routine}
</MenuItem>
))
})()}
</Menu>
</div>
)
}