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
56758f10
Commit
56758f10
authored
Oct 14, 2025
by
Steven
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
chore: refactor ActivityCalendar to use a calendar matrix and improve cell rendering
parent
5011eb5d
Changes
5
Show whitespace changes
Inline
Side-by-side
Showing
5 changed files
with
232 additions
and
158 deletions
+232
-158
ActivityCalendar.tsx
web/src/components/ActivityCalendar/ActivityCalendar.tsx
+53
-158
CalendarCell.tsx
web/src/components/ActivityCalendar/CalendarCell.tsx
+76
-0
types.ts
web/src/components/ActivityCalendar/types.ts
+19
-0
useCalendarMatrix.ts
web/src/components/ActivityCalendar/useCalendarMatrix.ts
+71
-0
utils.ts
web/src/components/ActivityCalendar/utils.ts
+13
-0
No files found.
web/src/components/ActivityCalendar/ActivityCalendar.tsx
View file @
56758f10
import
dayjs
from
"dayjs"
;
import
{
observer
}
from
"mobx-react-lite"
;
import
{
memo
,
useMemo
}
from
"react"
;
import
{
Tooltip
,
TooltipContent
,
TooltipProvider
,
TooltipTrigger
}
from
"@/components/ui/tooltip"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
TooltipProvider
}
from
"@/components/ui/tooltip"
;
import
{
workspaceStore
}
from
"@/store"
;
import
type
{
ActivityCalendarProps
,
CalendarDay
}
from
"@/types/statistics"
;
import
type
{
ActivityCalendarProps
}
from
"@/types/statistics"
;
import
{
useTranslate
}
from
"@/utils/i18n"
;
const
getCellOpacity
=
(
ratio
:
number
):
string
=>
{
if
(
ratio
===
0
)
return
""
;
if
(
ratio
>
0.75
)
return
"bg-primary text-primary-foreground"
;
if
(
ratio
>
0.5
)
return
"bg-primary/80 text-primary-foreground"
;
if
(
ratio
>
0.25
)
return
"bg-primary/60 text-primary-foreground"
;
return
"bg-primary/40 text-primary"
;
};
const
CalendarCell
=
memo
(
({
dayInfo
,
count
,
maxCount
,
isToday
,
isSelected
,
onClick
,
tooltipText
,
}:
{
dayInfo
:
CalendarDay
;
count
:
number
;
maxCount
:
number
;
isToday
:
boolean
;
isSelected
:
boolean
;
onClick
?:
()
=>
void
;
tooltipText
:
string
;
})
=>
{
const
cellContent
=
(
<
div
className=
{
cn
(
"w-6 h-6 text-xs lg:text-[13px] flex justify-center items-center cursor-default"
,
"rounded-lg border-2 text-muted-foreground transition-all duration-200"
,
dayInfo
.
isCurrentMonth
&&
getCellOpacity
(
count
/
maxCount
),
dayInfo
.
isCurrentMonth
&&
isToday
&&
"border-border"
,
dayInfo
.
isCurrentMonth
&&
isSelected
&&
"font-medium border-border"
,
dayInfo
.
isCurrentMonth
&&
!
isToday
&&
!
isSelected
&&
"border-transparent"
,
count
>
0
&&
"cursor-pointer hover:scale-110"
,
)
}
onClick=
{
count
>
0
?
onClick
:
undefined
}
>
{
dayInfo
.
day
}
</
div
>
);
if
(
!
dayInfo
.
isCurrentMonth
)
{
return
(
<
div
className=
{
cn
(
"w-6 h-6 text-xs lg:text-[13px] flex justify-center items-center cursor-default opacity-60 text-muted-foreground"
)
}
>
{
dayInfo
.
day
}
</
div
>
);
}
return
(
<
TooltipProvider
>
<
Tooltip
>
<
TooltipTrigger
asChild
>
<
div
className=
"shrink-0"
>
{
cellContent
}
</
div
>
</
TooltipTrigger
>
<
TooltipContent
>
<
p
>
{
tooltipText
}
</
p
>
</
TooltipContent
>
</
Tooltip
>
</
TooltipProvider
>
);
},
);
import
{
CalendarCell
}
from
"./CalendarCell"
;
import
{
useCalendarMatrix
}
from
"./useCalendarMatrix"
;
export
const
ActivityCalendar
=
memo
(
observer
((
props
:
ActivityCalendarProps
)
=>
{
const
t
=
useTranslate
();
const
{
month
:
monthStr
,
data
,
onClick
}
=
props
;
const
{
month
,
selectedDate
,
data
,
onClick
}
=
props
;
const
weekStartDayOffset
=
workspaceStore
.
state
.
generalSetting
.
weekStartDayOffset
;
const
{
days
,
weekDays
,
maxCount
}
=
useMemo
(()
=>
{
const
yearValue
=
dayjs
(
monthStr
).
toDate
().
getFullYear
();
const
monthValue
=
dayjs
(
monthStr
).
toDate
().
getMonth
();
const
dayInMonth
=
new
Date
(
yearValue
,
monthValue
+
1
,
0
).
getDate
();
const
firstDay
=
(((
new
Date
(
yearValue
,
monthValue
,
1
).
getDay
()
-
weekStartDayOffset
)
%
7
)
+
7
)
%
7
;
const
lastDay
=
new
Date
(
yearValue
,
monthValue
,
dayInMonth
).
getDay
()
-
weekStartDayOffset
;
const
prevMonthDays
=
new
Date
(
yearValue
,
monthValue
,
0
).
getDate
();
const
WEEK_DAYS
=
[
t
(
"days.sun"
),
t
(
"days.mon"
),
t
(
"days.tue"
),
t
(
"days.wed"
),
t
(
"days.thu"
),
t
(
"days.fri"
),
t
(
"days.sat"
)];
const
weekDaysOrdered
=
WEEK_DAYS
.
slice
(
weekStartDayOffset
).
concat
(
WEEK_DAYS
.
slice
(
0
,
weekStartDayOffset
));
const
daysArray
:
CalendarDay
[]
=
[];
// Previous month's days
for
(
let
i
=
firstDay
-
1
;
i
>=
0
;
i
--
)
{
daysArray
.
push
({
day
:
prevMonthDays
-
i
,
isCurrentMonth
:
false
});
}
// Current month's days
for
(
let
i
=
1
;
i
<=
dayInMonth
;
i
++
)
{
const
date
=
dayjs
(
`
${
yearValue
}
-
${
monthValue
+
1
}
-
${
i
}
`
).
format
(
"YYYY-MM-DD"
);
daysArray
.
push
({
day
:
i
,
isCurrentMonth
:
true
,
date
});
}
// Next month's days
for
(
let
i
=
1
;
i
<
7
-
lastDay
;
i
++
)
{
daysArray
.
push
({
day
:
i
,
isCurrentMonth
:
false
});
}
const
today
=
useMemo
(()
=>
dayjs
().
format
(
"YYYY-MM-DD"
),
[]);
const
selectedDateFormatted
=
useMemo
(()
=>
dayjs
(
selectedDate
).
format
(
"YYYY-MM-DD"
),
[
selectedDate
]);
const
maxCountValue
=
Math
.
max
(...
Object
.
values
(
data
),
1
);
const
weekDaysRaw
=
useMemo
(
()
=>
[
t
(
"days.sun"
),
t
(
"days.mon"
),
t
(
"days.tue"
),
t
(
"days.wed"
),
t
(
"days.thu"
),
t
(
"days.fri"
),
t
(
"days.sat"
)],
[
t
],
);
return
{
year
:
yearValue
,
month
:
monthValue
,
days
:
daysArray
,
weekDays
:
weekDaysOrdered
,
maxCount
:
maxCountValue
,
};
},
[
monthStr
,
data
,
weekStartDayOffset
,
t
]);
const
today
=
useMemo
(()
=>
dayjs
().
format
(
"YYYY-MM-DD"
),
[]);
const
selectedDateFormatted
=
useMemo
(()
=>
dayjs
(
props
.
selectedDate
).
format
(
"YYYY-MM-DD"
),
[
props
.
selectedDate
]);
const
{
weeks
,
weekDays
,
maxCount
}
=
useCalendarMatrix
({
month
,
data
,
weekDays
:
weekDaysRaw
,
weekStartDayOffset
,
today
,
selectedDate
:
selectedDateFormatted
,
});
return
(
<
div
className=
{
cn
(
"w-full h-auto shrink-0 grid grid-cols-7 grid-flow-row gap-1"
)
}
>
{
weekDays
.
map
((
day
,
index
)
=>
(
<
div
key=
{
index
}
className=
{
cn
(
"w-6 h-5 text-xs flex justify-center items-center cursor-default opacity-60"
)
}
>
{
day
}
<
TooltipProvider
>
<
div
className=
"w-full flex flex-col gap-1"
>
<
div
className=
"grid grid-cols-7 gap-1 text-xs text-muted-foreground"
>
{
weekDays
.
map
((
label
,
index
)
=>
(
<
div
key=
{
index
}
className=
"flex h-5 items-center justify-center text-muted-foreground/80"
>
{
label
}
</
div
>
))
}
{
days
.
map
((
dayInfo
,
index
)
=>
{
if
(
!
dayInfo
.
isCurrentMonth
)
{
return
(
<
CalendarCell
key=
{
`prev-next-${index}`
}
dayInfo=
{
dayInfo
}
count=
{
0
}
maxCount=
{
maxCount
}
isToday=
{
false
}
isSelected=
{
false
}
tooltipText=
""
/>
);
}
</
div
>
const
date
=
dayInfo
.
date
!
;
const
count
=
data
[
date
]
||
0
;
const
isToday
=
today
===
date
;
const
isSelected
=
selectedDateFormatted
===
date
;
<
div
className=
"grid grid-cols-7 gap-1"
>
{
weeks
.
map
((
week
,
weekIndex
)
=>
week
.
days
.
map
((
day
,
dayIndex
)
=>
{
const
tooltipText
=
count
===
0
?
date
day
.
count
===
0
?
day
.
date
:
t
(
"memo.count-memos-in-date"
,
{
count
:
count
,
memos
:
count
===
1
?
t
(
"common.memo"
)
:
t
(
"common.memos"
),
date
:
date
,
count
:
day
.
count
,
memos
:
day
.
count
===
1
?
t
(
"common.memo"
)
:
t
(
"common.memos"
),
date
:
day
.
date
,
}).
toLowerCase
();
return
(
<
CalendarCell
key=
{
date
}
dayInfo=
{
dayInfo
}
count=
{
count
}
key=
{
`${weekIndex}-${dayIndex}-${day.date}`
}
day=
{
day
}
maxCount=
{
maxCount
}
isToday=
{
isToday
}
isSelected=
{
isSelected
}
onClick=
{
()
=>
onClick
?.(
date
)
}
tooltipText=
{
tooltipText
}
onClick=
{
onClick
}
/>
);
})
}
}),
)
}
</
div
>
</
div
>
</
TooltipProvider
>
);
}),
);
...
...
web/src/components/ActivityCalendar/CalendarCell.tsx
0 → 100644
View file @
56758f10
import
{
memo
}
from
"react"
;
import
{
Tooltip
,
TooltipContent
,
TooltipTrigger
}
from
"@/components/ui/tooltip"
;
import
{
cn
}
from
"@/lib/utils"
;
import
type
{
CalendarDayCell
}
from
"./types"
;
import
{
getCellIntensityClass
}
from
"./utils"
;
interface
CalendarCellProps
{
day
:
CalendarDayCell
;
maxCount
:
number
;
tooltipText
:
string
;
onClick
?:
(
date
:
string
)
=>
void
;
}
export
const
CalendarCell
=
memo
((
props
:
CalendarCellProps
)
=>
{
const
{
day
,
maxCount
,
tooltipText
,
onClick
}
=
props
;
const
handleClick
=
()
=>
{
if
(
day
.
count
>
0
&&
onClick
)
{
onClick
(
day
.
date
);
}
};
const
baseClasses
=
"w-full h-7 rounded-md border text-xs 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"
;
const
isInteractive
=
Boolean
(
onClick
&&
day
.
count
>
0
);
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
>
);
}
const
intensityClass
=
getCellIntensityClass
(
day
,
maxCount
);
const
buttonClasses
=
cn
(
baseClasses
,
"border-transparent text-muted-foreground"
,
day
.
isToday
&&
"border-border"
,
day
.
isSelected
&&
"border-border font-medium"
,
day
.
isWeekend
&&
"text-muted-foreground/80"
,
intensityClass
,
isInteractive
?
"cursor-pointer hover:scale-105"
:
"cursor-default"
,
);
const
button
=
(
<
button
type=
"button"
onClick=
{
handleClick
}
tabIndex=
{
isInteractive
?
0
:
-
1
}
aria
-
label=
{
ariaLabel
}
aria
-
current=
{
day
.
isToday
?
"date"
:
undefined
}
aria
-
disabled=
{
!
isInteractive
}
className=
{
buttonClasses
}
>
{
day
.
label
}
</
button
>
);
if
(
!
tooltipText
)
{
return
button
;
}
return
(
<
Tooltip
>
<
TooltipTrigger
asChild
>
{
button
}
</
TooltipTrigger
>
<
TooltipContent
side=
"top"
>
<
p
>
{
tooltipText
}
</
p
>
</
TooltipContent
>
</
Tooltip
>
);
});
CalendarCell
.
displayName
=
"CalendarCell"
;
web/src/components/ActivityCalendar/types.ts
0 → 100644
View file @
56758f10
export
interface
CalendarDayCell
{
date
:
string
;
label
:
number
;
count
:
number
;
isCurrentMonth
:
boolean
;
isToday
:
boolean
;
isSelected
:
boolean
;
isWeekend
:
boolean
;
}
export
interface
CalendarDayRow
{
days
:
CalendarDayCell
[];
}
export
interface
CalendarMatrixResult
{
weeks
:
CalendarDayRow
[];
weekDays
:
string
[];
maxCount
:
number
;
}
web/src/components/ActivityCalendar/useCalendarMatrix.ts
0 → 100644
View file @
56758f10
import
dayjs
from
"dayjs"
;
import
{
useMemo
}
from
"react"
;
import
type
{
CalendarDayCell
,
CalendarMatrixResult
}
from
"./types"
;
interface
UseCalendarMatrixParams
{
month
:
string
;
data
:
Record
<
string
,
number
>
;
weekDays
:
string
[];
weekStartDayOffset
:
number
;
today
:
string
;
selectedDate
:
string
;
}
export
const
useCalendarMatrix
=
({
month
,
data
,
weekDays
,
weekStartDayOffset
,
today
,
selectedDate
,
}:
UseCalendarMatrixParams
):
CalendarMatrixResult
=>
{
return
useMemo
(()
=>
{
const
monthStart
=
dayjs
(
month
).
startOf
(
"month"
);
const
monthEnd
=
monthStart
.
endOf
(
"month"
);
const
monthKey
=
monthStart
.
format
(
"YYYY-MM"
);
const
orderedWeekDays
=
weekDays
.
slice
(
weekStartDayOffset
).
concat
(
weekDays
.
slice
(
0
,
weekStartDayOffset
));
const
startOffset
=
(
monthStart
.
day
()
-
weekStartDayOffset
+
7
)
%
7
;
const
endOffset
=
(
weekStartDayOffset
+
6
-
monthEnd
.
day
()
+
7
)
%
7
;
const
calendarStart
=
monthStart
.
subtract
(
startOffset
,
"day"
);
const
calendarEnd
=
monthEnd
.
add
(
endOffset
,
"day"
);
const
dayCount
=
calendarEnd
.
diff
(
calendarStart
,
"day"
)
+
1
;
const
weeks
:
CalendarMatrixResult
[
"weeks"
]
=
[];
let
maxCount
=
0
;
for
(
let
index
=
0
;
index
<
dayCount
;
index
+=
1
)
{
const
current
=
calendarStart
.
add
(
index
,
"day"
);
const
isoDate
=
current
.
format
(
"YYYY-MM-DD"
);
const
weekIndex
=
Math
.
floor
(
index
/
7
);
if
(
!
weeks
[
weekIndex
])
{
weeks
[
weekIndex
]
=
{
days
:
[]
};
}
const
isCurrentMonth
=
current
.
format
(
"YYYY-MM"
)
===
monthKey
;
const
count
=
data
[
isoDate
]
??
0
;
const
dayCell
:
CalendarDayCell
=
{
date
:
isoDate
,
label
:
current
.
date
(),
count
,
isCurrentMonth
,
isToday
:
isoDate
===
today
,
isSelected
:
isoDate
===
selectedDate
,
isWeekend
:
[
0
,
6
].
includes
(
current
.
day
()),
};
weeks
[
weekIndex
].
days
.
push
(
dayCell
);
maxCount
=
Math
.
max
(
maxCount
,
count
);
}
return
{
weeks
,
weekDays
:
orderedWeekDays
,
maxCount
:
Math
.
max
(
maxCount
,
1
),
};
},
[
month
,
data
,
weekDays
,
weekStartDayOffset
,
today
,
selectedDate
]);
};
web/src/components/ActivityCalendar/utils.ts
0 → 100644
View file @
56758f10
import
type
{
CalendarDayCell
}
from
"./types"
;
export
const
getCellIntensityClass
=
(
day
:
CalendarDayCell
,
maxCount
:
number
):
string
=>
{
if
(
!
day
.
isCurrentMonth
||
day
.
count
===
0
||
maxCount
<=
0
)
{
return
"bg-transparent"
;
}
const
ratio
=
day
.
count
/
maxCount
;
if
(
ratio
>
0.75
)
return
"bg-primary text-primary-foreground border-primary"
;
if
(
ratio
>
0.5
)
return
"bg-primary/80 text-primary-foreground border-primary/90"
;
if
(
ratio
>
0.25
)
return
"bg-primary/60 text-primary-foreground border-primary/70"
;
return
"bg-primary/40 text-primary"
;
};
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