Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
C
canifa_note
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Vũ Hoàng Anh
canifa_note
Commits
b826e902
Commit
b826e902
authored
Dec 29, 2025
by
Johnny
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
refactor: polish ActivityCalendar components with modern design
parent
5d677828
Changes
17
Hide whitespace changes
Inline
Side-by-side
Showing
17 changed files
with
193 additions
and
329 deletions
+193
-329
ActivityCalendar.tsx
web/src/components/ActivityCalendar/ActivityCalendar.tsx
+0
-63
CalendarCell.tsx
web/src/components/ActivityCalendar/CalendarCell.tsx
+5
-11
CalendarHeader.tsx
web/src/components/ActivityCalendar/CalendarHeader.tsx
+0
-73
CalendarPopover.tsx
web/src/components/ActivityCalendar/CalendarPopover.tsx
+0
-32
MonthCalendar.tsx
web/src/components/ActivityCalendar/MonthCalendar.tsx
+10
-9
MonthCard.tsx
web/src/components/ActivityCalendar/MonthCard.tsx
+0
-17
YearCalendar.tsx
web/src/components/ActivityCalendar/YearCalendar.tsx
+93
-0
constants.ts
web/src/components/ActivityCalendar/constants.ts
+14
-6
hooks.ts
web/src/components/ActivityCalendar/hooks.ts
+0
-14
index.ts
web/src/components/ActivityCalendar/index.ts
+4
-26
types.ts
web/src/components/ActivityCalendar/types.ts
+2
-9
useCalendar.ts
web/src/components/ActivityCalendar/useCalendar.ts
+8
-0
utils.ts
web/src/components/ActivityCalendar/utils.ts
+22
-7
MemoExplorer.tsx
web/src/components/MemoExplorer/MemoExplorer.tsx
+1
-6
MonthNavigator.tsx
web/src/components/StatisticsView/MonthNavigator.tsx
+29
-31
StatisticsView.tsx
web/src/components/StatisticsView/StatisticsView.tsx
+4
-7
statistics.ts
web/src/types/statistics.ts
+1
-18
No files found.
web/src/components/ActivityCalendar/ActivityCalendar.tsx
deleted
100644 → 0
View file @
5d677828
import
dayjs
from
"dayjs"
;
import
{
memo
,
useMemo
}
from
"react"
;
import
{
TooltipProvider
}
from
"@/components/ui/tooltip"
;
import
{
useInstance
}
from
"@/contexts/InstanceContext"
;
import
type
{
ActivityCalendarProps
}
from
"@/types/statistics"
;
import
{
useTranslate
}
from
"@/utils/i18n"
;
import
{
CalendarCell
}
from
"./CalendarCell"
;
import
{
getTooltipText
,
useTodayDate
,
useWeekdayLabels
}
from
"./shared"
;
import
{
useCalendarMatrix
}
from
"./useCalendarMatrix"
;
export
const
ActivityCalendar
=
memo
((
props
:
ActivityCalendarProps
)
=>
{
const
t
=
useTranslate
();
const
{
month
,
selectedDate
,
data
,
onClick
}
=
props
;
const
{
generalSetting
}
=
useInstance
();
const
weekStartDayOffset
=
generalSetting
.
weekStartDayOffset
;
const
today
=
useTodayDate
();
const
weekDaysRaw
=
useWeekdayLabels
();
const
selectedDateFormatted
=
useMemo
(()
=>
dayjs
(
selectedDate
).
format
(
"YYYY-MM-DD"
),
[
selectedDate
]);
const
{
weeks
,
weekDays
,
maxCount
}
=
useCalendarMatrix
({
month
,
data
,
weekDays
:
weekDaysRaw
,
weekStartDayOffset
,
today
,
selectedDate
:
selectedDateFormatted
,
});
return
(
<
TooltipProvider
>
<
div
className=
"w-full flex flex-col gap-0.5"
>
<
div
className=
"grid grid-cols-7 gap-0.5 text-xs text-muted-foreground"
>
{
weekDays
.
map
((
label
,
index
)
=>
(
<
div
key=
{
index
}
className=
"flex h-4 items-center justify-center text-muted-foreground/80"
>
{
label
}
</
div
>
))
}
</
div
>
<
div
className=
"grid grid-cols-7 gap-0.5"
>
{
weeks
.
map
((
week
,
weekIndex
)
=>
week
.
days
.
map
((
day
,
dayIndex
)
=>
{
const
tooltipText
=
getTooltipText
(
day
.
count
,
day
.
date
,
t
);
return
(
<
CalendarCell
key=
{
`${weekIndex}-${dayIndex}-${day.date}`
}
day=
{
day
}
maxCount=
{
maxCount
}
tooltipText=
{
tooltipText
}
onClick=
{
onClick
}
/>
);
}),
)
}
</
div
>
</
div
>
</
TooltipProvider
>
);
});
ActivityCalendar
.
displayName
=
"ActivityCalendar"
;
web/src/components/ActivityCalendar/CalendarCell.tsx
View file @
b826e902
...
...
@@ -26,7 +26,7 @@ export const CalendarCell = memo((props: CalendarCellProps) => {
const
smallExtraClasses
=
size
===
"small"
?
`
${
SMALL_CELL_SIZE
.
dimensions
}
min-h-0`
:
""
;
const
baseClasses
=
cn
(
"aspect-square w-full
border flex items-center justify-center text-center transition-transform duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 focus-visible:ring-offset-1 focus-visible:ring-offset-background
select-none"
,
"aspect-square w-full
flex items-center justify-center text-center transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 focus-visible:ring-offset-2
select-none"
,
sizeConfig
.
font
,
sizeConfig
.
borderRadius
,
smallExtraClasses
,
...
...
@@ -35,23 +35,17 @@ export const CalendarCell = memo((props: CalendarCellProps) => {
const
ariaLabel
=
day
.
isSelected
?
`
${
tooltipText
}
(selected)`
:
tooltipText
;
if
(
!
day
.
isCurrentMonth
)
{
return
(
<
div
className=
{
cn
(
baseClasses
,
"border-transparent text-muted-foreground/60 bg-transparent pointer-events-none opacity-80"
)
}
>
{
day
.
label
}
</
div
>
);
return
<
div
className=
{
cn
(
baseClasses
,
"text-muted-foreground/30 bg-transparent cursor-default"
)
}
>
{
day
.
label
}
</
div
>;
}
const
intensityClass
=
getCellIntensityClass
(
day
,
maxCount
);
const
buttonClasses
=
cn
(
baseClasses
,
"border-transparent text-muted-foreground"
,
(
day
.
isToday
||
day
.
isSelected
)
&&
"border-border"
,
day
.
isSelected
&&
"font-medium"
,
day
.
isWeekend
&&
"text-muted-foreground/80"
,
intensityClass
,
isInteractive
?
"cursor-pointer hover:scale-105"
:
"cursor-default"
,
day
.
isToday
&&
"ring-2 ring-primary/30 ring-offset-1 font-semibold z-10"
,
day
.
isSelected
&&
"ring-2 ring-primary ring-offset-1 font-bold z-10"
,
isInteractive
?
"cursor-pointer hover:scale-110 hover:shadow-md hover:z-20"
:
"cursor-default"
,
);
const
button
=
(
...
...
web/src/components/ActivityCalendar/CalendarHeader.tsx
deleted
100644 → 0
View file @
5d677828
import
{
ChevronLeftIcon
,
ChevronRightIcon
}
from
"lucide-react"
;
import
{
useMemo
}
from
"react"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
useTranslate
}
from
"@/utils/i18n"
;
interface
CalendarHeaderProps
{
selectedYear
:
number
;
onYearChange
:
(
year
:
number
)
=>
void
;
canGoPrev
:
boolean
;
canGoNext
:
boolean
;
}
export
const
CalendarHeader
=
({
selectedYear
,
onYearChange
,
canGoPrev
,
canGoNext
}:
CalendarHeaderProps
)
=>
{
const
t
=
useTranslate
();
const
currentYear
=
useMemo
(()
=>
new
Date
().
getFullYear
(),
[]);
const
isCurrentYear
=
selectedYear
===
currentYear
;
const
handlePrevYear
=
()
=>
{
if
(
canGoPrev
)
{
onYearChange
(
selectedYear
-
1
);
}
};
const
handleNextYear
=
()
=>
{
if
(
canGoNext
)
{
onYearChange
(
selectedYear
+
1
);
}
};
const
handleToday
=
()
=>
{
onYearChange
(
currentYear
);
};
return
(
<
div
className=
"flex items-center justify-between pb-2"
>
<
h2
className=
"text-2xl font-bold text-foreground tracking-tight leading-none"
>
{
selectedYear
}
</
h2
>
<
div
className=
"inline-flex items-center gap-2 shrink-0"
>
<
Button
variant=
"ghost"
size=
"icon"
onClick=
{
handlePrevYear
}
disabled=
{
!
canGoPrev
}
aria
-
label=
"Previous year"
className=
"rounded-full hover:bg-accent/50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
<
ChevronLeftIcon
/>
</
Button
>
<
Button
variant=
{
isCurrentYear
?
"secondary"
:
"ghost"
}
onClick=
{
handleToday
}
disabled=
{
isCurrentYear
}
aria
-
label=
{
t
(
"common.today"
)
}
className=
"bg-accent text-accent-foreground hover:bg-accent/50 text-muted-foreground hover:text-foreground h-9 px-4 rounded-full font-medium text-sm transition-colors cursor-default"
>
{
t
(
"common.today"
)
}
</
Button
>
<
Button
variant=
"ghost"
size=
"icon"
onClick=
{
handleNextYear
}
disabled=
{
!
canGoNext
}
aria
-
label=
"Next year"
className=
"rounded-full hover:bg-accent/50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
<
ChevronRightIcon
/>
</
Button
>
</
div
>
</
div
>
);
};
web/src/components/ActivityCalendar/CalendarPopover.tsx
deleted
100644 → 0
View file @
5d677828
import
{
useMemo
}
from
"react"
;
import
{
calculateYearMaxCount
,
filterDataByYear
,
generateMonthsForYear
}
from
"@/components/ActivityCalendar"
;
import
{
TooltipProvider
}
from
"@/components/ui/tooltip"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
CalendarHeader
}
from
"./CalendarHeader"
;
import
{
getMaxYear
,
MIN_YEAR
}
from
"./constants"
;
import
{
MonthCard
}
from
"./MonthCard"
;
import
type
{
CalendarPopoverProps
}
from
"./types"
;
export
const
CalendarPopover
=
({
selectedYear
,
data
,
onYearChange
,
onDateClick
,
className
}:
CalendarPopoverProps
)
=>
{
const
yearData
=
useMemo
(()
=>
filterDataByYear
(
data
,
selectedYear
),
[
data
,
selectedYear
]);
const
months
=
useMemo
(()
=>
generateMonthsForYear
(
selectedYear
),
[
selectedYear
]);
const
yearMaxCount
=
useMemo
(()
=>
calculateYearMaxCount
(
yearData
),
[
yearData
]);
const
canGoPrev
=
selectedYear
>
MIN_YEAR
;
const
canGoNext
=
selectedYear
<
getMaxYear
();
return
(
<
div
className=
{
cn
(
"w-full max-w-4xl flex flex-col gap-3 p-3"
,
className
)
}
>
<
CalendarHeader
selectedYear=
{
selectedYear
}
onYearChange=
{
onYearChange
}
canGoPrev=
{
canGoPrev
}
canGoNext=
{
canGoNext
}
/>
<
TooltipProvider
>
<
div
className=
"w-full animate-fade-in"
>
<
div
className=
"grid gap-2 sm:gap-2.5 md:gap-3 lg:gap-3 grid-cols-2 sm:grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4"
>
{
months
.
map
((
month
)
=>
(
<
MonthCard
key=
{
month
}
month=
{
month
}
data=
{
yearData
}
maxCount=
{
yearMaxCount
}
onClick=
{
onDateClick
}
/>
))
}
</
div
>
</
div
>
</
TooltipProvider
>
</
div
>
);
};
web/src/components/ActivityCalendar/
Compact
MonthCalendar.tsx
→
web/src/components/ActivityCalendar/MonthCalendar.tsx
View file @
b826e902
...
...
@@ -4,12 +4,13 @@ import { cn } from "@/lib/utils";
import
{
useTranslate
}
from
"@/utils/i18n"
;
import
{
CalendarCell
}
from
"./CalendarCell"
;
import
{
DEFAULT_CELL_SIZE
,
SMALL_CELL_SIZE
}
from
"./constants"
;
import
{
getTooltipText
,
useTodayDate
,
useWeekdayLabels
}
from
"./shared"
;
import
type
{
CompactMonthCalendarProps
}
from
"./types"
;
import
{
useCalendarMatrix
}
from
"./useCalendarMatrix"
;
import
{
useTodayDate
,
useWeekdayLabels
}
from
"./hooks"
;
import
type
{
MonthCalendarProps
}
from
"./types"
;
import
{
useCalendarMatrix
}
from
"./useCalendar"
;
import
{
getTooltipText
}
from
"./utils"
;
export
const
CompactMonthCalendar
=
memo
((
props
:
Compact
MonthCalendarProps
)
=>
{
const
{
month
,
data
,
maxCount
,
size
=
"default"
,
onClick
}
=
props
;
export
const
MonthCalendar
=
memo
((
props
:
MonthCalendarProps
)
=>
{
const
{
month
,
data
,
maxCount
,
size
=
"default"
,
onClick
,
className
}
=
props
;
const
t
=
useTranslate
();
const
{
generalSetting
}
=
useInstance
();
...
...
@@ -30,10 +31,10 @@ export const CompactMonthCalendar = memo((props: CompactMonthCalendarProps) => {
const
sizeConfig
=
size
===
"small"
?
SMALL_CELL_SIZE
:
DEFAULT_CELL_SIZE
;
return
(
<
div
className=
"flex flex-col gap-1"
>
<
div
className=
{
cn
(
"grid grid-cols-7
gap-0.5 text-muted-foreground
"
,
size
===
"small"
?
"text-[10px]"
:
"text-xs"
)
}
>
<
div
className=
{
cn
(
"flex flex-col gap-2"
,
className
)
}
>
<
div
className=
{
cn
(
"grid grid-cols-7
"
,
sizeConfig
.
gap
,
"text-muted-foreground mb-1
"
,
size
===
"small"
?
"text-[10px]"
:
"text-xs"
)
}
>
{
rotatedWeekDays
.
map
((
label
,
index
)
=>
(
<
div
key=
{
index
}
className=
"flex h-4 items-center justify-center text-muted-foreground/
50
"
>
<
div
key=
{
index
}
className=
"flex h-4 items-center justify-center text-muted-foreground/
60 font-medium
"
>
{
label
}
</
div
>
))
}
...
...
@@ -61,4 +62,4 @@ export const CompactMonthCalendar = memo((props: CompactMonthCalendarProps) => {
);
});
CompactMonthCalendar
.
displayName
=
"Compact
MonthCalendar"
;
MonthCalendar
.
displayName
=
"
MonthCalendar"
;
web/src/components/ActivityCalendar/MonthCard.tsx
deleted
100644 → 0
View file @
5d677828
import
{
CompactMonthCalendar
,
getMonthLabel
}
from
"@/components/ActivityCalendar"
;
import
{
cn
}
from
"@/lib/utils"
;
import
type
{
MonthCardProps
}
from
"./types"
;
export
const
MonthCard
=
({
month
,
data
,
maxCount
,
onClick
,
className
}:
MonthCardProps
)
=>
{
return
(
<
div
className=
{
cn
(
"flex flex-col gap-1 sm:gap-1.5 rounded-lg border bg-card p-1.5 sm:p-2 md:p-2.5 shadow-sm hover:shadow-md hover:border-border/60 transition-all duration-200"
,
className
,
)
}
>
<
div
className=
"text-xs font-semibold text-foreground text-center tracking-tight"
>
{
getMonthLabel
(
month
)
}
</
div
>
<
CompactMonthCalendar
month=
{
month
}
data=
{
data
}
maxCount=
{
maxCount
}
size=
"small"
onClick=
{
onClick
}
/>
</
div
>
);
};
web/src/components/ActivityCalendar/YearCalendar.tsx
0 → 100644
View file @
b826e902
import
{
ChevronLeftIcon
,
ChevronRightIcon
}
from
"lucide-react"
;
import
{
useMemo
}
from
"react"
;
import
{
calculateYearMaxCount
,
filterDataByYear
,
generateMonthsForYear
,
getMonthLabel
,
MonthCalendar
,
}
from
"@/components/ActivityCalendar"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
TooltipProvider
}
from
"@/components/ui/tooltip"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
useTranslate
}
from
"@/utils/i18n"
;
import
{
getMaxYear
,
MIN_YEAR
}
from
"./constants"
;
import
type
{
YearCalendarProps
}
from
"./types"
;
export
const
YearCalendar
=
({
selectedYear
,
data
,
onYearChange
,
onDateClick
,
className
}:
YearCalendarProps
)
=>
{
const
t
=
useTranslate
();
const
currentYear
=
useMemo
(()
=>
new
Date
().
getFullYear
(),
[]);
const
yearData
=
useMemo
(()
=>
filterDataByYear
(
data
,
selectedYear
),
[
data
,
selectedYear
]);
const
months
=
useMemo
(()
=>
generateMonthsForYear
(
selectedYear
),
[
selectedYear
]);
const
yearMaxCount
=
useMemo
(()
=>
calculateYearMaxCount
(
yearData
),
[
yearData
]);
const
canGoPrev
=
selectedYear
>
MIN_YEAR
;
const
canGoNext
=
selectedYear
<
getMaxYear
();
const
isCurrentYear
=
selectedYear
===
currentYear
;
const
handlePrevYear
=
()
=>
canGoPrev
&&
onYearChange
(
selectedYear
-
1
);
const
handleNextYear
=
()
=>
canGoNext
&&
onYearChange
(
selectedYear
+
1
);
const
handleToday
=
()
=>
onYearChange
(
currentYear
);
return
(
<
div
className=
{
cn
(
"w-full flex flex-col gap-6 p-2 md:p-0 select-none"
,
className
)
}
>
<
div
className=
"flex items-center justify-between pb-4 px-2 pt-2"
>
<
h2
className=
"text-3xl font-bold text-foreground tracking-tight leading-none"
>
{
selectedYear
}
</
h2
>
<
div
className=
"inline-flex items-center gap-1 shrink-0"
>
<
Button
variant=
"ghost"
size=
"sm"
onClick=
{
handlePrevYear
}
disabled=
{
!
canGoPrev
}
aria
-
label=
"Previous year"
className=
"h-9 w-9 p-0 rounded-md hover:bg-secondary/80 text-muted-foreground hover:text-foreground"
>
<
ChevronLeftIcon
className=
"w-5 h-5"
/>
</
Button
>
<
Button
variant=
"ghost"
size=
"sm"
onClick=
{
handleToday
}
disabled=
{
isCurrentYear
}
aria
-
label=
{
t
(
"common.today"
)
}
className=
{
cn
(
"h-9 px-4 rounded-md text-sm font-medium transition-colors"
,
isCurrentYear
?
"bg-secondary/50 text-muted-foreground cursor-default"
:
"hover:bg-secondary/80 text-foreground"
,
)
}
>
{
t
(
"common.today"
)
}
</
Button
>
<
Button
variant=
"ghost"
size=
"sm"
onClick=
{
handleNextYear
}
disabled=
{
!
canGoNext
}
aria
-
label=
"Next year"
className=
"h-9 w-9 p-0 rounded-md hover:bg-secondary/80 text-muted-foreground hover:text-foreground"
>
<
ChevronRightIcon
className=
"w-5 h-5"
/>
</
Button
>
</
div
>
</
div
>
<
TooltipProvider
>
<
div
className=
"w-full animate-fade-in"
>
<
div
className=
"grid gap-6 grid-cols-1 sm:grid-cols-2 md:grid-cols-3"
>
{
months
.
map
((
month
)
=>
(
<
div
key=
{
month
}
className=
"flex flex-col gap-3 rounded-lg p-3 hover:bg-secondary/40 transition-colors cursor-default border border-transparent hover:border-border/50"
>
<
div
className=
"text-xs font-bold text-foreground/80 uppercase tracking-widest pl-1"
>
{
getMonthLabel
(
month
)
}
</
div
>
<
MonthCalendar
month=
{
month
}
data=
{
yearData
}
maxCount=
{
yearMaxCount
}
size=
"small"
onClick=
{
onDateClick
}
/>
</
div
>
))
}
</
div
>
</
div
>
</
TooltipProvider
>
</
div
>
);
};
web/src/components/ActivityCalendar/constants.ts
View file @
b826e902
...
...
@@ -13,15 +13,23 @@ export const INTENSITY_THRESHOLDS = {
MINIMAL
:
0
,
}
as
const
;
export
const
CELL_STYLES
=
{
HIGH
:
"bg-primary text-primary-foreground shadow-sm"
,
MEDIUM
:
"bg-primary/80 text-primary-foreground shadow-sm"
,
LOW
:
"bg-primary/60 text-primary-foreground shadow-sm"
,
MINIMAL
:
"bg-primary/40 text-foreground"
,
EMPTY
:
"bg-secondary/30 text-muted-foreground hover:bg-secondary/50"
,
}
as
const
;
export
const
SMALL_CELL_SIZE
=
{
font
:
"text-
[10px]
"
,
dimensions
:
"
max-w-6 max-h-6
"
,
borderRadius
:
"rounded-
sm
"
,
gap
:
"gap-
px
"
,
font
:
"text-
xs
"
,
dimensions
:
"
w-8 h-8 mx-auto
"
,
borderRadius
:
"rounded-
md
"
,
gap
:
"gap-
1
"
,
}
as
const
;
export
const
DEFAULT_CELL_SIZE
=
{
font
:
"text-xs"
,
borderRadius
:
"rounded"
,
gap
:
"gap-
0
.5"
,
borderRadius
:
"rounded
-md
"
,
gap
:
"gap-
1
.5"
,
}
as
const
;
web/src/components/ActivityCalendar/
shared
.ts
→
web/src/components/ActivityCalendar/
hooks
.ts
View file @
b826e902
...
...
@@ -2,8 +2,6 @@ import dayjs from "dayjs";
import
{
useMemo
}
from
"react"
;
import
{
useTranslate
}
from
"@/utils/i18n"
;
export
type
TranslateFunction
=
ReturnType
<
typeof
useTranslate
>
;
export
const
useWeekdayLabels
=
()
=>
{
const
t
=
useTranslate
();
return
useMemo
(()
=>
[
t
(
"days.sun"
),
t
(
"days.mon"
),
t
(
"days.tue"
),
t
(
"days.wed"
),
t
(
"days.thu"
),
t
(
"days.fri"
),
t
(
"days.sat"
)],
[
t
]);
...
...
@@ -12,15 +10,3 @@ export const useWeekdayLabels = () => {
export
const
useTodayDate
=
()
=>
{
return
dayjs
().
format
(
"YYYY-MM-DD"
);
};
export
const
getTooltipText
=
(
count
:
number
,
date
:
string
,
t
:
TranslateFunction
):
string
=>
{
if
(
count
===
0
)
{
return
date
;
}
return
t
(
"memo.count-memos-in-date"
,
{
count
,
memos
:
count
===
1
?
t
(
"common.memo"
)
:
t
(
"common.memos"
),
date
,
}).
toLowerCase
();
};
web/src/components/ActivityCalendar/index.ts
View file @
b826e902
export
{
ActivityCalendar
as
default
}
from
"./ActivityCalendar"
;
export
{
CalendarCell
,
type
CalendarCellProps
}
from
"./CalendarCell"
;
export
{
CalendarHeader
}
from
"./CalendarHeader"
;
export
{
CalendarPopover
}
from
"./CalendarPopover"
;
export
{
CompactMonthCalendar
}
from
"./CompactMonthCalendar"
;
export
*
from
"./constants"
;
export
{
MonthCard
}
from
"./MonthCard"
;
export
{
getTooltipText
,
type
TranslateFunction
,
useTodayDate
,
useWeekdayLabels
}
from
"./shared"
;
export
type
{
CalendarDayCell
,
CalendarDayRow
,
CalendarMatrixResult
,
CalendarPopoverProps
,
CalendarSize
,
CompactMonthCalendarProps
,
MonthCardProps
,
}
from
"./types"
;
export
{
type
UseCalendarMatrixParams
,
useCalendarMatrix
}
from
"./useCalendarMatrix"
;
export
{
calculateYearMaxCount
,
filterDataByYear
,
generateMonthsForYear
,
getCellIntensityClass
,
getMonthLabel
,
hasActivityData
,
}
from
"./utils"
;
export
*
from
"./MonthCalendar"
;
export
*
from
"./types"
;
export
*
from
"./utils"
;
export
*
from
"./YearCalendar"
;
web/src/components/ActivityCalendar/types.ts
View file @
b826e902
...
...
@@ -20,23 +20,16 @@ export interface CalendarMatrixResult {
maxCount
:
number
;
}
export
interface
Compact
MonthCalendarProps
{
export
interface
MonthCalendarProps
{
month
:
string
;
data
:
Record
<
string
,
number
>
;
maxCount
:
number
;
size
?:
CalendarSize
;
onClick
?:
(
date
:
string
)
=>
void
;
}
export
interface
MonthCardProps
{
month
:
string
;
data
:
Record
<
string
,
number
>
;
maxCount
:
number
;
onClick
?:
(
date
:
string
)
=>
void
;
className
?:
string
;
}
export
interface
CalendarPopove
rProps
{
export
interface
YearCalenda
rProps
{
selectedYear
:
number
;
data
:
Record
<
string
,
number
>
;
onYearChange
:
(
year
:
number
)
=>
void
;
...
...
web/src/components/ActivityCalendar/useCalendar
Matrix
.ts
→
web/src/components/ActivityCalendar/useCalendar.ts
View file @
b826e902
...
...
@@ -45,6 +45,9 @@ const calculateCalendarBoundaries = (monthStart: dayjs.Dayjs, weekStartDayOffset
return
{
calendarStart
,
dayCount
};
};
/**
* Generates a matrix of calendar days for a given month, handling week alignment and data mapping.
*/
export
const
useCalendarMatrix
=
({
month
,
data
,
...
...
@@ -54,16 +57,20 @@ export const useCalendarMatrix = ({
selectedDate
,
}:
UseCalendarMatrixParams
):
CalendarMatrixResult
=>
{
return
useMemo
(()
=>
{
// Determine the start of the month and its formatted key (YYYY-MM)
const
monthStart
=
dayjs
(
month
).
startOf
(
"month"
);
const
monthKey
=
monthStart
.
format
(
"YYYY-MM"
);
// Rotate week labels based on the user's preferred start of the week
const
rotatedWeekDays
=
weekDays
.
slice
(
weekStartDayOffset
).
concat
(
weekDays
.
slice
(
0
,
weekStartDayOffset
));
// Calculate the start and end dates for the calendar grid to ensure full weeks
const
{
calendarStart
,
dayCount
}
=
calculateCalendarBoundaries
(
monthStart
,
weekStartDayOffset
);
const
weeks
:
CalendarMatrixResult
[
"weeks"
]
=
[];
let
maxCount
=
0
;
// Iterate through each day in the calendar grid
for
(
let
index
=
0
;
index
<
dayCount
;
index
+=
1
)
{
const
current
=
calendarStart
.
add
(
index
,
"day"
);
const
weekIndex
=
Math
.
floor
(
index
/
DAYS_IN_WEEK
);
...
...
@@ -72,6 +79,7 @@ export const useCalendarMatrix = ({
weeks
[
weekIndex
]
=
{
days
:
[]
};
}
// Create the day cell object with data and status flags
const
dayCell
=
createCalendarDayCell
(
current
,
monthKey
,
data
,
today
,
selectedDate
);
weeks
[
weekIndex
].
days
.
push
(
dayCell
);
maxCount
=
Math
.
max
(
maxCount
,
dayCell
.
count
);
...
...
web/src/components/ActivityCalendar/utils.ts
View file @
b826e902
import
dayjs
from
"dayjs"
;
import
isSameOrAfter
from
"dayjs/plugin/isSameOrAfter"
;
import
isSameOrBefore
from
"dayjs/plugin/isSameOrBefore"
;
import
{
INTENSITY_THRESHOLDS
,
MIN_COUNT
,
MONTHS_IN_YEAR
}
from
"./constants"
;
import
{
useTranslate
}
from
"@/utils/i18n"
;
import
{
CELL_STYLES
,
INTENSITY_THRESHOLDS
,
MIN_COUNT
,
MONTHS_IN_YEAR
}
from
"./constants"
;
import
type
{
CalendarDayCell
}
from
"./types"
;
dayjs
.
extend
(
isSameOrAfter
);
dayjs
.
extend
(
isSameOrBefore
);
export
type
TranslateFunction
=
ReturnType
<
typeof
useTranslate
>
;
export
const
getCellIntensityClass
=
(
day
:
CalendarDayCell
,
maxCount
:
number
):
string
=>
{
if
(
!
day
.
isCurrentMonth
||
day
.
count
===
0
)
{
return
"bg-transparent"
;
return
CELL_STYLES
.
EMPTY
;
}
const
ratio
=
day
.
count
/
maxCount
;
if
(
ratio
>
INTENSITY_THRESHOLDS
.
HIGH
)
return
"bg-primary text-primary-foreground border-primary"
;
if
(
ratio
>
INTENSITY_THRESHOLDS
.
MEDIUM
)
return
"bg-primary/80 text-primary-foreground border-primary/90"
;
if
(
ratio
>
INTENSITY_THRESHOLDS
.
LOW
)
return
"bg-primary/60 text-primary-foreground border-primary/70"
;
return
"bg-primary/40 text-primary"
;
if
(
ratio
>
INTENSITY_THRESHOLDS
.
HIGH
)
return
CELL_STYLES
.
HIGH
;
if
(
ratio
>
INTENSITY_THRESHOLDS
.
MEDIUM
)
return
CELL_STYLES
.
MEDIUM
;
if
(
ratio
>
INTENSITY_THRESHOLDS
.
LOW
)
return
CELL_STYLES
.
LOW
;
return
CELL_STYLES
.
MINIMAL
;
};
export
const
generateMonthsForYear
=
(
year
:
number
):
string
[]
=>
{
...
...
@@ -32,7 +35,7 @@ export const calculateYearMaxCount = (data: Record<string, number>): number => {
};
export
const
getMonthLabel
=
(
month
:
string
):
string
=>
{
return
dayjs
(
month
).
format
(
"MMM
YYYY
"
);
return
dayjs
(
month
).
format
(
"MMM"
);
};
export
const
filterDataByYear
=
(
data
:
Record
<
string
,
number
>
,
year
:
number
):
Record
<
string
,
number
>
=>
{
...
...
@@ -55,3 +58,15 @@ export const filterDataByYear = (data: Record<string, number>, year: number): Re
export
const
hasActivityData
=
(
data
:
Record
<
string
,
number
>
):
boolean
=>
{
return
Object
.
values
(
data
).
some
((
count
)
=>
count
>
0
);
};
export
const
getTooltipText
=
(
count
:
number
,
date
:
string
,
t
:
TranslateFunction
):
string
=>
{
if
(
count
===
0
)
{
return
date
;
}
return
t
(
"memo.count-memos-in-date"
,
{
count
,
memos
:
count
===
1
?
t
(
"common.memo"
)
:
t
(
"common.memos"
),
date
,
}).
toLowerCase
();
};
web/src/components/MemoExplorer/MemoExplorer.tsx
View file @
b826e902
...
...
@@ -13,7 +13,6 @@ export interface MemoExplorerFeatures {
statistics
?:
boolean
;
shortcuts
?:
boolean
;
tags
?:
boolean
;
statisticsContext
?:
MemoExplorerContext
;
}
interface
Props
{
...
...
@@ -32,7 +31,6 @@ const getDefaultFeatures = (context: MemoExplorerContext): MemoExplorerFeatures
statistics
:
true
,
shortcuts
:
false
,
// Global explore doesn't use shortcuts
tags
:
true
,
statisticsContext
:
"explore"
,
};
case
"archived"
:
return
{
...
...
@@ -40,7 +38,6 @@ const getDefaultFeatures = (context: MemoExplorerContext): MemoExplorerFeatures
statistics
:
true
,
shortcuts
:
false
,
// Archived doesn't typically use shortcuts
tags
:
true
,
statisticsContext
:
"archived"
,
};
case
"profile"
:
return
{
...
...
@@ -48,7 +45,6 @@ const getDefaultFeatures = (context: MemoExplorerContext): MemoExplorerFeatures
statistics
:
true
,
shortcuts
:
false
,
// Profile view doesn't use shortcuts
tags
:
true
,
statisticsContext
:
"profile"
,
};
case
"home"
:
default
:
...
...
@@ -57,7 +53,6 @@ const getDefaultFeatures = (context: MemoExplorerContext): MemoExplorerFeatures
statistics
:
true
,
shortcuts
:
true
,
tags
:
true
,
statisticsContext
:
"home"
,
};
}
};
...
...
@@ -81,7 +76,7 @@ const MemoExplorer = (props: Props) => {
>
{
features
.
search
&&
<
SearchBar
/>
}
<
div
className=
"mt-1 px-1 w-full"
>
{
features
.
statistics
&&
<
StatisticsView
context=
{
features
.
statisticsContext
}
statisticsData=
{
statisticsData
}
/>
}
{
features
.
statistics
&&
<
StatisticsView
statisticsData=
{
statisticsData
}
/>
}
{
features
.
shortcuts
&&
currentUser
&&
<
ShortcutsSection
/>
}
{
features
.
tags
&&
<
TagsSection
readonly=
{
context
===
"explore"
}
tagCount=
{
tagCount
}
/>
}
</
div
>
...
...
web/src/components/StatisticsView/MonthNavigator.tsx
View file @
b826e902
import
{
ChevronLeftIcon
,
ChevronRightIcon
}
from
"lucide-react"
;
import
{
Chevron
DownIcon
,
Chevron
LeftIcon
,
ChevronRightIcon
}
from
"lucide-react"
;
import
{
useState
}
from
"react"
;
import
{
CalendarPopover
}
from
"@/components/ActivityCalendar"
;
import
{
Popover
,
PopoverContent
,
PopoverTrigger
}
from
"@/components/ui/popover"
;
import
useCurrentUser
from
"@/hooks/useCurrentUser"
;
import
{
useFilteredMemoStats
}
from
"@/hooks/useFilteredMemoStats"
;
import
{
YearCalendar
}
from
"@/components/ActivityCalendar"
;
import
{
Dialog
,
DialogContent
,
DialogTitle
,
DialogTrigger
}
from
"@/components/ui/dialog"
;
import
i18n
from
"@/i18n"
;
import
{
addMonths
,
formatMonth
,
getMonthFromDate
,
getYearFromDate
,
setYearAndMonth
}
from
"@/lib/calendar-utils"
;
import
type
{
MonthNavigatorProps
}
from
"@/types/statistics"
;
export
const
MonthNavigator
=
({
visibleMonth
,
onMonthChange
}:
MonthNavigatorProps
)
=>
{
const
currentUser
=
useCurrentUser
();
export
const
MonthNavigator
=
({
visibleMonth
,
onMonthChange
,
activityStats
}:
MonthNavigatorProps
)
=>
{
const
[
isOpen
,
setIsOpen
]
=
useState
(
false
);
const
currentMonth
=
new
Date
(
visibleMonth
);
const
currentYear
=
getYearFromDate
(
visibleMonth
);
const
currentMonthNum
=
getMonthFromDate
(
visibleMonth
);
const
{
statistics
}
=
useFilteredMemoStats
({
userName
:
currentUser
?.
name
,
});
const
handlePrevMonth
=
()
=>
{
onMonthChange
(
addMonths
(
visibleMonth
,
-
1
));
};
...
...
@@ -37,28 +30,33 @@ export const MonthNavigator = ({ visibleMonth, onMonthChange }: MonthNavigatorPr
};
return
(
<
div
className=
"w-full mb-
1
flex flex-row justify-between items-center gap-1"
>
<
Popover
open=
{
isOpen
}
onOpenChange=
{
setIsOpen
}
>
<
Popover
Trigger
asChild
>
<
span
className=
"relative text-sm text-muted-foreground cursor-pointer hover:text-foreground transition-colors
"
>
<
div
className=
"w-full mb-
2
flex flex-row justify-between items-center gap-1"
>
<
Dialog
open=
{
isOpen
}
onOpenChange=
{
setIsOpen
}
>
<
Dialog
Trigger
asChild
>
<
button
className=
"px-2 py-1 -ml-2 rounded-md hover:bg-secondary/50 text-sm text-foreground font-semibold transition-colors flex items-center gap-1 select-none group
"
>
{
currentMonth
.
toLocaleString
(
i18n
.
language
,
{
year
:
"numeric"
,
month
:
"long"
})
}
</
span
>
</
PopoverTrigger
>
<
PopoverContent
className=
"p-0"
align=
"start"
>
<
CalendarPopover
selectedYear=
{
currentYear
}
data=
{
statistics
.
activityStats
}
onYearChange=
{
handleYearChange
}
onDateClick=
{
handleDateClick
}
/>
</
PopoverContent
>
</
Popover
>
<
div
className=
"flex justify-end items-center shrink-0 gap-1"
>
<
button
className=
"cursor-pointer hover:opacity-80 transition-opacity"
onClick=
{
handlePrevMonth
}
aria
-
label=
"Previous month"
>
<
ChevronLeftIcon
className=
"w-5 h-auto shrink-0 opacity-40"
/>
<
ChevronDownIcon
className=
"w-3.5 h-3.5 text-muted-foreground group-hover:text-foreground transition-colors"
/>
</
button
>
</
DialogTrigger
>
<
DialogContent
className=
"p-0 border-none bg-background md:max-w-4xl"
size=
"2xl"
showCloseButton=
{
false
}
>
<
DialogTitle
className=
"sr-only"
>
Select Month
</
DialogTitle
>
<
YearCalendar
selectedYear=
{
currentYear
}
data=
{
activityStats
}
onYearChange=
{
handleYearChange
}
onDateClick=
{
handleDateClick
}
/>
</
DialogContent
>
</
Dialog
>
<
div
className=
"flex justify-end items-center shrink-0 gap-0.5"
>
<
button
className=
"p-1 rounded-md hover:bg-secondary/50 text-muted-foreground hover:text-foreground transition-all"
onClick=
{
handlePrevMonth
}
aria
-
label=
"Previous month"
>
<
ChevronLeftIcon
className=
"w-4 h-4"
/>
</
button
>
<
button
className=
"cursor-pointer hover:opacity-80 transition-opacity"
onClick=
{
handleNextMonth
}
aria
-
label=
"Next month"
>
<
ChevronRightIcon
className=
"w-5 h-auto shrink-0 opacity-40"
/>
<
button
className=
"p-1 rounded-md hover:bg-secondary/50 text-muted-foreground hover:text-foreground transition-all"
onClick=
{
handleNextMonth
}
aria
-
label=
"Next month"
>
<
ChevronRightIcon
className=
"w-4 h-4"
/>
</
button
>
</
div
>
</
div
>
...
...
web/src/components/StatisticsView/StatisticsView.tsx
View file @
b826e902
import
dayjs
from
"dayjs"
;
import
{
useMemo
,
useState
}
from
"react"
;
import
{
Compact
MonthCalendar
}
from
"@/components/ActivityCalendar"
;
import
{
MonthCalendar
}
from
"@/components/ActivityCalendar"
;
import
{
useDateFilterNavigation
}
from
"@/hooks"
;
import
type
{
StatisticsData
}
from
"@/types/statistics"
;
import
{
MonthNavigator
}
from
"./MonthNavigator"
;
export
type
StatisticsViewContext
=
"home"
|
"explore"
|
"archived"
|
"profile"
;
interface
Props
{
context
?:
StatisticsViewContext
;
statisticsData
:
StatisticsData
;
}
...
...
@@ -24,11 +21,11 @@ const StatisticsView = (props: Props) => {
},
[
activityStats
]);
return
(
<
div
className=
"group w-full mt-2
space-y
-1 text-muted-foreground animate-fade-in"
>
<
MonthNavigator
visibleMonth=
{
visibleMonthString
}
onMonthChange=
{
setVisibleMonthString
}
/>
<
div
className=
"group w-full mt-2
flex flex-col gap
-1 text-muted-foreground animate-fade-in"
>
<
MonthNavigator
visibleMonth=
{
visibleMonthString
}
onMonthChange=
{
setVisibleMonthString
}
activityStats=
{
activityStats
}
/>
<
div
className=
"w-full animate-scale-in"
>
<
Compact
MonthCalendar
month=
{
visibleMonthString
}
data=
{
activityStats
}
maxCount=
{
maxCount
}
onClick=
{
navigateToDateFilter
}
/>
<
MonthCalendar
month=
{
visibleMonthString
}
data=
{
activityStats
}
maxCount=
{
maxCount
}
onClick=
{
navigateToDateFilter
}
/>
</
div
>
</
div
>
);
...
...
web/src/types/statistics.ts
View file @
b826e902
export
interface
ActivityData
{
date
:
string
;
count
:
number
;
}
export
interface
CalendarDay
{
day
:
number
;
isCurrentMonth
:
boolean
;
date
?:
string
;
}
export
interface
StatisticsViewProps
{
className
?:
string
;
}
...
...
@@ -16,13 +5,7 @@ export interface StatisticsViewProps {
export
interface
MonthNavigatorProps
{
visibleMonth
:
string
;
onMonthChange
:
(
month
:
string
)
=>
void
;
}
export
interface
ActivityCalendarProps
{
month
:
string
;
selectedDate
:
string
;
data
:
Record
<
string
,
number
>
;
onClick
?:
(
date
:
string
)
=>
void
;
activityStats
:
Record
<
string
,
number
>
;
}
export
interface
StatisticsData
{
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment