diff --git a/app/features/img-export/components/SeasonSummaryGraphic.module.css b/app/features/img-export/components/SeasonSummaryGraphic.module.css index 4a1508feb..94de1337a 100644 --- a/app/features/img-export/components/SeasonSummaryGraphic.module.css +++ b/app/features/img-export/components/SeasonSummaryGraphic.module.css @@ -151,17 +151,54 @@ below win regardless of which CSS chunk loads last } .calendar { - display: grid; - grid-auto-flow: column; - grid-template-rows: repeat(7, auto); + --calendar-cell-size: 18px; + --calendar-gap: 4px; + + display: flex; + align-items: flex-end; justify-content: center; - gap: 4px; - margin-top: var(--s-4); + gap: var(--calendar-gap); + margin-top: var(--s-2-5); +} + +.calendarWeekdays { + display: flex; + flex-direction: column; + gap: var(--calendar-gap); + margin-inline-end: var(--s-1-5); +} + +.calendarWeekday { + display: flex; + align-items: center; + justify-content: flex-end; + height: var(--calendar-cell-size); +} + +.calendarMonth { + display: flex; + flex-direction: column; + gap: var(--calendar-gap); +} + +.calendarMonthName { + text-align: center; +} + +.calendarWeeks { + display: flex; + gap: var(--calendar-gap); +} + +.calendarWeek { + display: flex; + flex-direction: column; + gap: var(--calendar-gap); } .calendarCell { - width: 18px; - height: 18px; + width: var(--calendar-cell-size, 18px); + height: var(--calendar-cell-size, 18px); border-radius: 3px; background-color: var(--activity-none); diff --git a/app/features/img-export/components/SeasonSummaryGraphic.tsx b/app/features/img-export/components/SeasonSummaryGraphic.tsx index 86f0deee7..75409b066 100644 --- a/app/features/img-export/components/SeasonSummaryGraphic.tsx +++ b/app/features/img-export/components/SeasonSummaryGraphic.tsx @@ -8,6 +8,7 @@ import { } from "date-fns"; import * as React from "react"; import { useTranslation } from "react-i18next"; +import * as R from "remeda"; import { Avatar } from "~/components/Avatar"; import { Flag } from "~/components/Flag"; import { TierImage, WeaponImage } from "~/components/Image"; @@ -43,6 +44,11 @@ const CHART_MARGIN = { top: 26, right: 14, bottom: 22, left: 14 }; const CHART_POINTS_NEEDED = 2; const CHART_PEAK_LABEL_CLAMP = 48; const TOP_MATES_COUNT = 3; +const CALENDAR_WEEK_LENGTH = 7; +/** Thursday, the day that decides which month a week column belongs to */ +const CALENDAR_WEEK_MONTH_DAY_INDEX = 3; +/** Monday and Friday, the only rows the calendar names */ +const CALENDAR_NAMED_WEEKDAY_INDICES = [0, 4]; /** Without weapons the teammates box is alone next to the activity calendar, so it has room for more */ const TOP_MATES_COUNT_WITHOUT_WEAPONS = 6; @@ -533,36 +539,119 @@ function ActivityCalendar({ seasonDateRange: { starts: Date; ends: Date }; activeDays: Array<{ date: string; activity: SeasonSummaryGraphicActivity }>; }) { + const { formatter } = useDateTimeFormat({ month: "long" }); + const activityByDay = new Map( activeDays.map((day) => [day.date, day.activity]), ); const seasonFirstDay = startOfDay(seasonDateRange.starts); - const days = eachDayOfInterval({ - start: startOfWeek(seasonFirstDay, { weekStartsOn: 1 }), - end: seasonDateRange.ends, + const weeks = seasonWeeks({ + seasonFirstDay, + seasonLastDay: seasonDateRange.ends, }); + const months = calendarMonths(weeks); return (
- {days.map((day) => { - const key = format(day, "yyyy-MM-dd"); - const beforeSeason = day.getTime() < seasonFirstDay.getTime(); + + {months.map((month) => ( +
+ + {formatter.format(month.month)} + +
+ {month.weeks.map((week) => ( +
+ {week.map((day) => { + const key = format(day, "yyyy-MM-dd"); + const beforeSeason = day.getTime() < seasonFirstDay.getTime(); - return ( -
- ); - })} + return ( +
+ ); + })} +
+ ))} +
+
+ ))}
); } +function CalendarWeekdays({ firstWeek }: { firstWeek: Date[] }) { + const { formatter } = useDateTimeFormat({ weekday: "short" }); + + return ( + + {firstWeek.map((day, dayIndex) => ( +
+ {CALENDAR_NAMED_WEEKDAY_INDICES.includes(dayIndex) + ? formatter.format(day) + : null} +
+ ))} +
+ ); +} + +/** + * The season's Monday to Sunday week columns. A season ending mid-week (its last hours can fall on the + * next day depending on the time zone) would leave a ragged column, so an incomplete last week is left out. + */ +function seasonWeeks({ + seasonFirstDay, + seasonLastDay, +}: { + seasonFirstDay: Date; + seasonLastDay: Date; +}): Date[][] { + const weeks: Date[][] = R.chunk( + eachDayOfInterval({ + start: startOfWeek(seasonFirstDay, { weekStartsOn: 1 }), + end: seasonLastDay, + }), + CALENDAR_WEEK_LENGTH, + ); + + const lastWeek = weeks[weeks.length - 1]; + if (weeks.length > 1 && lastWeek.length < CALENDAR_WEEK_LENGTH) { + weeks.pop(); + } + + return weeks; +} + +/** Groups the week columns under the month that holds most of the week */ +function calendarMonths(weeks: Date[][]) { + const months: Array<{ key: string; month: Date; weeks: Date[][] }> = []; + + for (const week of weeks) { + const monthDay = + week[CALENDAR_WEEK_MONTH_DAY_INDEX] ?? week[week.length - 1]; + const key = format(monthDay, "yyyy-MM"); + const latestMonth = months[months.length - 1]; + + if (latestMonth?.key === key) { + latestMonth.weeks.push(week); + } else { + months.push({ key, month: monthDay, weeks: [week] }); + } + } + + return months; +} + function ActivityLegend() { const { t } = useTranslation(["user"]);