fe591f6be7
* fix stats calendar daylight saving time offset bug Previously, when computing counts for the calendar in the stats menu, it was assumed that days had 86,400 seconds. However, this assumption does not hold true on the day when daylight savings occurs. * add self to CONTRIBUTORS and about.py * fix stats calendar anki day to calendar day mapping Since Anki days don't necessarily roll over at midnight, mapping an Anki day into a calendar day needs to have a linear shift applied. By providing the frontend with access to the scheduler's rollover hour, we can account for this offset.
220 lines
7.2 KiB
TypeScript
220 lines
7.2 KiB
TypeScript
// Copyright: Ankitects Pty Ltd and contributors
|
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|
|
|
import * as tr from "@tslib/ftl";
|
|
import { localizedDate, weekdayLabel } from "@tslib/i18n";
|
|
import { Stats } from "@tslib/proto";
|
|
import type { CountableTimeInterval } from "d3";
|
|
import { timeHour } from "d3";
|
|
import {
|
|
interpolateBlues,
|
|
pointer,
|
|
scaleLinear,
|
|
scaleSequentialSqrt,
|
|
select,
|
|
timeDay,
|
|
timeFriday,
|
|
timeMonday,
|
|
timeSaturday,
|
|
timeSunday,
|
|
timeYear,
|
|
} from "d3";
|
|
|
|
import type { GraphBounds, SearchDispatch } from "./graph-helpers";
|
|
import { RevlogRange, setDataAvailable } from "./graph-helpers";
|
|
import { clickableClass } from "./graph-styles";
|
|
import { hideTooltip, showTooltip } from "./tooltip";
|
|
|
|
export interface GraphData {
|
|
// indexed by day, where day is relative to today
|
|
reviewCount: Map<number, number>;
|
|
timeFunction: CountableTimeInterval;
|
|
weekdayLabels: number[];
|
|
rolloverHour: number;
|
|
}
|
|
|
|
interface DayDatum {
|
|
day: number;
|
|
count: number;
|
|
// 0-51
|
|
weekNumber: number;
|
|
// 0-6
|
|
weekDay: number;
|
|
date: Date;
|
|
}
|
|
|
|
type WeekdayType = Stats.GraphPreferences.Weekday;
|
|
const Weekday = Stats.GraphPreferences.Weekday; /* enum */
|
|
|
|
export function gatherData(
|
|
data: Stats.GraphsResponse,
|
|
firstDayOfWeek: WeekdayType,
|
|
): GraphData {
|
|
const reviewCount = new Map(
|
|
Object.entries(data.reviews!.count).map(([k, v]) => {
|
|
return [Number(k), v.learn + v.relearn + v.mature + v.filtered + v.young];
|
|
}),
|
|
);
|
|
const timeFunction = timeFunctionForDay(firstDayOfWeek);
|
|
const weekdayLabels: number[] = [];
|
|
for (let i = 0; i < 7; i++) {
|
|
weekdayLabels.push((firstDayOfWeek + i) % 7);
|
|
}
|
|
|
|
return { reviewCount, timeFunction, weekdayLabels, rolloverHour: data.rolloverHour };
|
|
}
|
|
|
|
export function renderCalendar(
|
|
svgElem: SVGElement,
|
|
bounds: GraphBounds,
|
|
sourceData: GraphData,
|
|
dispatch: SearchDispatch,
|
|
targetYear: number,
|
|
nightMode: boolean,
|
|
revlogRange: RevlogRange,
|
|
setFirstDayOfWeek: (d: number) => void,
|
|
): void {
|
|
const svg = select(svgElem);
|
|
const now = new Date();
|
|
const nowForYear = new Date();
|
|
nowForYear.setFullYear(targetYear);
|
|
|
|
const x = scaleLinear()
|
|
.range([bounds.marginLeft, bounds.width - bounds.marginRight])
|
|
.domain([-1, 53]);
|
|
|
|
// map of 0-365 -> day
|
|
const dayMap: Map<number, DayDatum> = new Map();
|
|
let maxCount = 0;
|
|
for (const [day, count] of sourceData.reviewCount.entries()) {
|
|
let date = timeDay.offset(now, day);
|
|
// anki day does not necessarily roll over at midnight, we account for this when mapping onto calendar days
|
|
date = timeHour.offset(date, -1 * sourceData.rolloverHour);
|
|
if (count > maxCount) {
|
|
maxCount = count;
|
|
}
|
|
if (date.getFullYear() != targetYear) {
|
|
continue;
|
|
}
|
|
const weekNumber = sourceData.timeFunction.count(timeYear(date), date);
|
|
const weekDay = timeDay.count(sourceData.timeFunction(date), date);
|
|
const yearDay = timeDay.count(timeYear(date), date);
|
|
dayMap.set(yearDay, { day, count, weekNumber, weekDay, date } as DayDatum);
|
|
}
|
|
|
|
if (!maxCount) {
|
|
setDataAvailable(svg, false);
|
|
return;
|
|
} else {
|
|
setDataAvailable(svg, true);
|
|
}
|
|
|
|
// fill in any blanks, including the current calendar day even if the anki day has not rolled over
|
|
const startDate = timeYear(nowForYear);
|
|
const oneYearAgoFromNow = new Date(now);
|
|
oneYearAgoFromNow.setFullYear(now.getFullYear() - 1);
|
|
for (let i = 0; i < 365; i++) {
|
|
const date = timeDay.offset(startDate, i);
|
|
if (date > now) {
|
|
// don't fill out future dates
|
|
continue;
|
|
}
|
|
if (revlogRange == RevlogRange.Year && date < oneYearAgoFromNow) {
|
|
// don't fill out dates older than a year
|
|
continue;
|
|
}
|
|
const yearDay = timeDay.count(timeYear(date), date);
|
|
if (!dayMap.has(yearDay)) {
|
|
const weekNumber = sourceData.timeFunction.count(timeYear(date), date);
|
|
const weekDay = timeDay.count(sourceData.timeFunction(date), date);
|
|
dayMap.set(yearDay, {
|
|
day: yearDay,
|
|
count: 0,
|
|
weekNumber,
|
|
weekDay,
|
|
date,
|
|
} as DayDatum);
|
|
}
|
|
}
|
|
const data = Array.from(dayMap.values());
|
|
const cappedRange = scaleLinear().range([0.2, nightMode ? 0.8 : 1]);
|
|
const blues = scaleSequentialSqrt()
|
|
.domain([0, maxCount])
|
|
.interpolator((n) => interpolateBlues(cappedRange(n)!));
|
|
|
|
function tooltipText(d: DayDatum): string {
|
|
const date = localizedDate(d.date, {
|
|
weekday: "long",
|
|
year: "numeric",
|
|
month: "long",
|
|
day: "numeric",
|
|
});
|
|
const cards = tr.statisticsReviews({ reviews: d.count });
|
|
return `${date}<br>${cards}`;
|
|
}
|
|
|
|
const height = bounds.height / 10;
|
|
const emptyColour = nightMode ? "#333" : "#ddd";
|
|
|
|
svg.select("g.weekdays")
|
|
.selectAll("text")
|
|
.data(sourceData.weekdayLabels)
|
|
.join("text")
|
|
.text((d: number) => weekdayLabel(d))
|
|
.attr("width", x(-1)! - 2)
|
|
.attr("height", height - 2)
|
|
.attr("x", x(1)! - 3)
|
|
.attr("y", (_d, index) => bounds.marginTop + index * height)
|
|
.attr("fill", nightMode ? "#ddd" : "black")
|
|
.attr("dominant-baseline", "hanging")
|
|
.attr("text-anchor", "end")
|
|
.attr("font-size", "small")
|
|
.attr("font-family", "monospace")
|
|
.attr("direction", "ltr")
|
|
.style("user-select", "none")
|
|
.on("click", null)
|
|
.filter((d: number) =>
|
|
[Weekday.SUNDAY, Weekday.MONDAY, Weekday.FRIDAY, Weekday.SATURDAY].includes(
|
|
d,
|
|
)
|
|
)
|
|
.on("click", (_event: MouseEvent, d: number) => setFirstDayOfWeek(d));
|
|
|
|
svg.select("g.days")
|
|
.selectAll("rect")
|
|
.data(data)
|
|
.join("rect")
|
|
.attr("fill", emptyColour)
|
|
.attr("width", (d: DayDatum) => x(d.weekNumber + 1)! - x(d.weekNumber)! - 2)
|
|
.attr("height", height - 2)
|
|
.attr("x", (d: DayDatum) => x(d.weekNumber + 1)!)
|
|
.attr("y", (d: DayDatum) => bounds.marginTop + d.weekDay * height)
|
|
.on("mousemove", (event: MouseEvent, d: DayDatum) => {
|
|
const [x, y] = pointer(event, document.body);
|
|
showTooltip(tooltipText(d), x, y);
|
|
})
|
|
.on("mouseout", hideTooltip)
|
|
.attr("class", (d: DayDatum): string => (d.count > 0 ? clickableClass : ""))
|
|
.on("click", function(_event: MouseEvent, d: DayDatum) {
|
|
if (d.count > 0) {
|
|
dispatch("search", { query: `"prop:rated=${d.day}"` });
|
|
}
|
|
})
|
|
.transition()
|
|
.duration(800)
|
|
.attr("fill", (d: DayDatum) => (d.count === 0 ? emptyColour : blues(d.count)!));
|
|
}
|
|
|
|
function timeFunctionForDay(firstDayOfWeek: WeekdayType): CountableTimeInterval {
|
|
switch (firstDayOfWeek) {
|
|
case Weekday.MONDAY:
|
|
return timeMonday;
|
|
case Weekday.FRIDAY:
|
|
return timeFriday;
|
|
case Weekday.SATURDAY:
|
|
return timeSaturday;
|
|
default:
|
|
return timeSunday;
|
|
}
|
|
}
|