mirror of
https://github.com/sechan100/daily-routine-2.git
synced 2026-07-22 12:00:31 +00:00
feat: RoutineAchivementCalendar에서 루틴별로 성과를 볼 수 있는 기능을 완성
This commit is contained in:
parent
1a289a9b6f
commit
f2640fa5cb
11 changed files with 284 additions and 105 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
43
src/shared/components/use-child-ref.ts
Normal file
43
src/shared/components/use-child-ref.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
23
src/widgets/routine-achivement/model/use-routine-selector.ts
Normal file
23
src/widgets/routine-achivement/model/use-routine-selector.ts
Normal 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) });
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
|
@ -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",
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
96
src/widgets/routine-achivement/ui/RoutineSelector.tsx
Normal file
96
src/widgets/routine-achivement/ui/RoutineSelector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue