fix(calendar): show unscheduled markers on future days off
This commit is contained in:
@@ -2,7 +2,7 @@ import { useCalendarCache } from "./CalendarCache";
|
|||||||
import { Modal } from "./design-system/Modal";
|
import { Modal } from "./design-system/Modal";
|
||||||
import { Pencil } from "lucide-react";
|
import { Pencil } from "lucide-react";
|
||||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
import type { CalendarResponse } from "../shared/calendar";
|
import { toHeatmapDays, type CalendarResponse } from "../shared/calendar";
|
||||||
import { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard";
|
import { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard";
|
||||||
import { CalendarHeatmap } from "./design-system/CalendarHeatmap";
|
import { CalendarHeatmap } from "./design-system/CalendarHeatmap";
|
||||||
import { monthWindow, type MonthView } from "./design-system/calendar-model";
|
import { monthWindow, type MonthView } from "./design-system/calendar-model";
|
||||||
@@ -86,19 +86,7 @@ export function HabitHistory({
|
|||||||
return () => { active = false; };
|
return () => { active = false; };
|
||||||
}, [cache, habit.habitId, date, revision, attempt, range.from, range.to]);
|
}, [cache, habit.habitId, date, revision, attempt, range.from, range.to]);
|
||||||
|
|
||||||
const days = useMemo(() =>
|
const days = useMemo(() => calendar ? toHeatmapDays(calendar) : [], [calendar]);
|
||||||
calendar?.days.map((day) => ({
|
|
||||||
date: day.date,
|
|
||||||
color: day.color,
|
|
||||||
unit: day.habits[0]?.unit,
|
|
||||||
value: day.habits[0]?.value ?? 0,
|
|
||||||
target: day.habits[0]?.target ?? 0,
|
|
||||||
state: day.future
|
|
||||||
? ("future" as const)
|
|
||||||
: day.due
|
|
||||||
? ("due" as const)
|
|
||||||
: ("not-due" as const),
|
|
||||||
})) ?? [], [calendar]);
|
|
||||||
const steps = Math.max(
|
const steps = Math.max(
|
||||||
1,
|
1,
|
||||||
calendar?.days.find((day) => day.date === date)?.shadeCount ??
|
calendar?.days.find((day) => day.date === date)?.shadeCount ??
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export function CalendarHeatmap({
|
|||||||
const [customRange, setCustomRange] = useState<{ from: string; to: string } | null>(null);
|
const [customRange, setCustomRange] = useState<{ from: string; to: string } | null>(null);
|
||||||
const [rangeError, setRangeError] = useState("");
|
const [rangeError, setRangeError] = useState("");
|
||||||
const latestDate =
|
const latestDate =
|
||||||
allDays.findLast((day) => day.state !== "future")?.date ??
|
allDays.findLast((day) => !(day.future ?? day.state === "future"))?.date ??
|
||||||
allDays.at(-1)?.date;
|
allDays.at(-1)?.date;
|
||||||
// Inspecting a square changes selection, not the window. Only an external
|
// Inspecting a square changes selection, not the window. Only an external
|
||||||
// date outside the visible preset needs a new anchor to bring it into view.
|
// date outside the visible preset needs a new anchor to bring it into view.
|
||||||
|
|||||||
@@ -262,3 +262,20 @@ test("custom timeframe rejects missing, reversed and unavailable dates without c
|
|||||||
expect(description(container.querySelector("input")!)).toContain(error!);
|
expect(description(container.querySelector("input")!)).toContain(error!);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("upcoming days off render the not-scheduled marker without moving selection past today", async () => {
|
||||||
|
await act(async () => root.render(<CalendarHeatmap unit="completion" label="Weekly habit" days={[
|
||||||
|
{ date: "2026-09-04", value: 0, target: 1, state: "due", future: false },
|
||||||
|
{ date: "2026-09-05", value: 0, target: 1, state: "not-due", future: true },
|
||||||
|
{ date: "2026-09-06", value: 0, target: 1, state: "future", future: true },
|
||||||
|
{ date: "2026-09-07", value: 0, target: 1, state: "not-due", future: true },
|
||||||
|
]} />));
|
||||||
|
const days = [...container.querySelectorAll<HTMLButtonElement>(".ds-day")];
|
||||||
|
expect(days.map(day => day.className)).toEqual([
|
||||||
|
"ds-day ds-day--due", "ds-day ds-day--not-due", "ds-day ds-day--future", "ds-day ds-day--not-due",
|
||||||
|
]);
|
||||||
|
expect(days[0]!.getAttribute("aria-pressed")).toBe("true");
|
||||||
|
expect(days[1]!.getAttribute("aria-label")).toContain("Nothing scheduled");
|
||||||
|
expect(days[2]!.getAttribute("aria-label")).toContain("Upcoming");
|
||||||
|
expect(days[1]!.style.backgroundColor).toBe("transparent");
|
||||||
|
});
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export type CalendarDay = {
|
|||||||
date: string;
|
date: string;
|
||||||
value: number;
|
value: number;
|
||||||
target: number;
|
target: number;
|
||||||
|
future?: boolean;
|
||||||
state: "due" | "not-due" | "future";
|
state: "due" | "not-due" | "future";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -326,7 +326,7 @@ export class HabitService {
|
|||||||
const ratio = due ? Math.min(1, value / target!) : null;
|
const ratio = due ? Math.min(1, value / target!) : null;
|
||||||
return { date, habitId: id, calendarRevision: this.owned(id).calendarRevision, name: config?.name ?? null, method: config?.method ?? null, revisionId: revision?.id ?? null,
|
return { date, habitId: id, calendarRevision: this.owned(id).calendarRevision, name: config?.name ?? null, method: config?.method ?? null, revisionId: revision?.id ?? null,
|
||||||
timezone: record?.timezone ?? this.user.timezone, endsAt: record?.endsAt ?? null, due, future,
|
timezone: record?.timezone ?? this.user.timezone, endsAt: record?.endsAt ?? null, due, future,
|
||||||
status: future ? 'future' : !due ? 'not_due' : ratio === 1 ? 'complete' : value > 0 ? 'partial' : 'empty',
|
status: !due ? 'not_due' : future ? 'future' : ratio === 1 ? 'complete' : value > 0 ? 'partial' : 'empty',
|
||||||
value, target, carriedFrom: counted?.carriedFrom ?? null, loggedCount: record?.countSet ? record.count : null, unit: config?.method === 'count' ? config.unit : config?.method === 'tasks' ? 'tasks' : 'completion',
|
value, target, carriedFrom: counted?.carriedFrom ?? null, loggedCount: record?.countSet ? record.count : null, unit: config?.method === 'count' ? config.unit : config?.method === 'tasks' ? 'tasks' : 'completion',
|
||||||
ratio, complete: due && ratio === 1, tasks, requirements: config ?? null };
|
ratio, complete: due && ratio === 1, tasks, requirements: config ?? null };
|
||||||
}
|
}
|
||||||
@@ -409,15 +409,15 @@ export class HabitService {
|
|||||||
const steps = manual ? 1 : settings.shadeCount === 'auto' ? combined ? 4 : details[0]!.target ?? 1 : settings.shadeCount;
|
const steps = manual ? 1 : settings.shadeCount === 'auto' ? combined ? 4 : details[0]!.target ?? 1 : settings.shadeCount;
|
||||||
const progress = shade(ratio, steps, settings, !combined && (settings.shadeCount === 'auto' || manual));
|
const progress = shade(ratio, steps, settings, !combined && (settings.shadeCount === 'auto' || manual));
|
||||||
const future = date > this.today;
|
const future = date > this.today;
|
||||||
days.push({ date, due: due.length, completed, ratio, future, status: future ? 'future' : ratio === null ? 'not_due' : ratio === 1 ? 'complete' : ratio > 0 ? 'partial' : 'empty',
|
days.push({ date, due: due.length, completed, ratio, future, status: ratio === null ? 'not_due' : future ? 'future' : ratio === 1 ? 'complete' : ratio > 0 ? 'partial' : 'empty',
|
||||||
shadeCount: steps, ...progress, color: future ? settings.futureColor : progress.color, habits: details.map(detail => {
|
shadeCount: steps, ...progress, color: future && ratio !== null ? settings.futureColor : progress.color, habits: details.map(detail => {
|
||||||
const { tasks, requirements, ...summary } = detail;
|
const { tasks, requirements, ...summary } = detail;
|
||||||
return { ...summary, tasks: compact ? undefined : tasks, requirements: compact ? undefined : requirements };
|
return { ...summary, tasks: compact ? undefined : tasks, requirements: compact ? undefined : requirements };
|
||||||
}) });
|
}) });
|
||||||
}
|
}
|
||||||
return { kind: combined ? 'combined' as const : 'individual' as const, from, to, today: this.today, timezone: this.user.timezone, settings, days,
|
return { kind: combined ? 'combined' as const : 'individual' as const, from, to, today: this.today, timezone: this.user.timezone, settings, days,
|
||||||
// Explicit colors and details are supplied for the client's Nivo colorScale/tooltip adapters.
|
// Explicit colors and details are supplied for the client's Nivo colorScale/tooltip adapters.
|
||||||
data: days.map(d => ({ day: d.date, value: d.future ? -2 : d.ratio === null ? -1 : d.level! })) };
|
data: days.map(d => ({ day: d.date, value: d.ratio === null ? -1 : d.future ? -2 : d.level! })) };
|
||||||
}
|
}
|
||||||
setTimezone(timezone: string) {
|
setTimezone(timezone: string) {
|
||||||
this.db.update(users).set({ timezone, updatedAt: sql`max(${users.updatedAt} + 1, ${this.now})` }).where(eq(users.id, this.user.id)).run();
|
this.db.update(users).set({ timezone, updatedAt: sql`max(${users.updatedAt} + 1, ${this.now})` }).where(eq(users.id, this.user.id)).run();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { expect, test } from 'bun:test';
|
import { expect, test } from 'bun:test';
|
||||||
import { fixture } from '../habits/test-fixture';
|
import { fixture } from '../habits/test-fixture';
|
||||||
import { toResponsiveCalendarProps } from './calendar';
|
import { toHeatmapDays, toResponsiveCalendarProps } from './calendar';
|
||||||
test('Nivo adapter preserves per-date colors, neutral/future states and readable exact counts', async () => {
|
test('Nivo adapter preserves per-date colors, neutral/future states and readable exact counts', async () => {
|
||||||
const f = fixture();
|
const f = fixture();
|
||||||
try {
|
try {
|
||||||
@@ -21,3 +21,38 @@ test('Nivo adapter preserves per-date colors, neutral/future states and readable
|
|||||||
expect(toResponsiveCalendarProps(combined).valueFormat(0)).toBe('0 of 1 habits complete');
|
expect(toResponsiveCalendarProps(combined).valueFormat(0)).toBe('0 of 1 habits complete');
|
||||||
} finally { f.close(); }
|
} finally { f.close(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('future calendars preserve scheduled days and days off for individual and combined charts', async () => {
|
||||||
|
const f = fixture();
|
||||||
|
try {
|
||||||
|
for (const method of ['manual', 'count', 'tasks']) {
|
||||||
|
const schedule = { type: 'weekdays', days: [1, 3, 5] };
|
||||||
|
const h = await f.json('/habits', 'POST', {
|
||||||
|
name: method, method, ...(method === 'count' ? { target: 8 } : {}),
|
||||||
|
...(method === 'tasks' ? { tasks: [{ name: 'Exercise', schedule }] } : { schedule }),
|
||||||
|
}, 201);
|
||||||
|
const c = await f.json('/charts', 'POST', { name: method, habitIds: [h.id] }, 201);
|
||||||
|
for (const path of [`/habits/${h.id}`, `/charts/${c.id}`]) {
|
||||||
|
const payload = await f.json(`${path}/calendar?from=2026-09-04&to=2026-09-11`);
|
||||||
|
const props = toResponsiveCalendarProps(payload);
|
||||||
|
const heatmap = toHeatmapDays(payload);
|
||||||
|
expect(heatmap.map(day => day.state)).toEqual([
|
||||||
|
'due', 'not-due', 'not-due', 'future', 'not-due', 'future', 'not-due', 'future',
|
||||||
|
]);
|
||||||
|
for (let index = 1; index < payload.days.length; index++) {
|
||||||
|
const day = payload.days[index];
|
||||||
|
expect(day.future).toBe(true);
|
||||||
|
expect(heatmap[index]!.future).toBe(true);
|
||||||
|
expect(day.status).toBe(day.due ? 'future' : 'not_due');
|
||||||
|
expect(props.colorScale(index)).toBe(day.due ? payload.settings.futureColor : payload.settings.notDueColor);
|
||||||
|
expect(props.valueFormat(index)).toBe(day.due ? 'Upcoming' : 'Nothing scheduled');
|
||||||
|
expect(payload.data[index].value).toBe(day.due ? -2 : -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const off = await f.json(`/habits/${h.id}/days/2026-09-05`);
|
||||||
|
expect(off.future).toBe(true);
|
||||||
|
expect(off.status).toBe('not_due');
|
||||||
|
expect((await f.request(`/habits/${h.id}/days/2026-09-05/progress`, 'PUT', method === 'count' ? { count: 1 } : { done: true })).status).toBe(409);
|
||||||
|
}
|
||||||
|
} finally { f.close(); }
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,10 +1,24 @@
|
|||||||
import { ResponsiveCalendar } from '@nivo/calendar';
|
import type { ResponsiveCalendar } from '@nivo/calendar';
|
||||||
import type { ComponentProps } from 'react';
|
import type { ComponentProps } from 'react';
|
||||||
|
import type { CalendarDay } from '../components/design-system/calendar-model';
|
||||||
import type { HabitService } from '../habits/service';
|
import type { HabitService } from '../habits/service';
|
||||||
|
|
||||||
export type CalendarResponse = ReturnType<HabitService['calendar']>;
|
export type CalendarResponse = ReturnType<HabitService['calendar']>;
|
||||||
export type ResponsiveCalendarProps = ComponentProps<typeof ResponsiveCalendar>;
|
export type ResponsiveCalendarProps = ComponentProps<typeof ResponsiveCalendar>;
|
||||||
|
|
||||||
|
/** Preserve timing independently so upcoming days off do not move the selected date. */
|
||||||
|
export function toHeatmapDays(calendar: CalendarResponse): CalendarDay[] {
|
||||||
|
return calendar.days.map(day => ({
|
||||||
|
date: day.date,
|
||||||
|
color: day.color,
|
||||||
|
unit: day.habits[0]?.unit,
|
||||||
|
value: day.habits[0]?.value ?? 0,
|
||||||
|
target: day.habits[0]?.target ?? 0,
|
||||||
|
future: day.future,
|
||||||
|
state: !day.due ? 'not-due' : day.future ? 'future' : 'due',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/** Data adapter only: no frontend component or page is rendered here. */
|
/** Data adapter only: no frontend component or page is rendered here. */
|
||||||
export function toResponsiveCalendarProps(calendar: CalendarResponse) {
|
export function toResponsiveCalendarProps(calendar: CalendarResponse) {
|
||||||
// Nivo calls colorScale with a value, not a date. Give each date a stable
|
// Nivo calls colorScale with a value, not a date. Give each date a stable
|
||||||
@@ -21,8 +35,8 @@ export function toResponsiveCalendarProps(calendar: CalendarResponse) {
|
|||||||
valueFormat: (index: number) => {
|
valueFormat: (index: number) => {
|
||||||
const day = calendar.days[index];
|
const day = calendar.days[index];
|
||||||
if (!day) return '';
|
if (!day) return '';
|
||||||
if (day.future) return 'Upcoming';
|
|
||||||
if (day.ratio === null) return 'Nothing scheduled';
|
if (day.ratio === null) return 'Nothing scheduled';
|
||||||
|
if (day.future) return 'Upcoming';
|
||||||
// Counts for combined charts are always equal-weight completed habits.
|
// Counts for combined charts are always equal-weight completed habits.
|
||||||
if (calendar.kind === 'combined') return `${day.completed} of ${day.due} habits complete`;
|
if (calendar.kind === 'combined') return `${day.completed} of ${day.due} habits complete`;
|
||||||
const habit = day.habits[0]!;
|
const habit = day.habits[0]!;
|
||||||
|
|||||||
Reference in New Issue
Block a user