Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 40 additions & 4 deletions src/app/views/events-render.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import type { CalendarEvent, HeatmapBucket } from '@nbtca/nbtcal';
import { type, space } from '../../core/theme.js';
import { t } from '../../i18n/index.js';
import { glyph, type, space } from '../../core/theme.js';
import { fmt, t } from '../../i18n/index.js';
import { type ListField, renderListFieldWithContext } from '../fields/list-field.js';
import { offlineNotice } from '../chrome.js';
import type { TextField } from '../fields/text-field.js';
import {
renderCountdownBanner,
renderEventBriefLines,
eventWhen,
type Event,
} from '../../features/calendar.js';
import { renderHeatmap } from '../../features/calendar-heatmap.js';
Expand All @@ -24,6 +25,8 @@ export interface EventsViewState {
stale?: boolean;
nextEvent?: Event;
heatmapBuckets?: HeatmapBucket[];
heatmapCursor?: string;
heatmapDay?: Event[];
recentEvents?: Event[];
hubField?: ListField;
listField?: ListField;
Expand Down Expand Up @@ -51,13 +54,43 @@ export function hubShowsHeatmap(bodyRows: number, buckets: readonly HeatmapBucke
return bodyRows >= EXPANDED_HUB_MIN_BODY_ROWS && buckets.length > 0;
}

function heatmapLines(buckets: HeatmapBucket[], now: Date, cols?: number): string[] {
function heatmapLines(
buckets: HeatmapBucket[],
now: Date,
cols?: number,
cursor?: string,
): string[] {
return renderHeatmap(buckets, now, {
color: true,
...(cols === undefined ? {} : { cols }),
...(cursor === undefined ? {} : { cursor }),
}).split('\n');
}

function heatmapDayLines(state: EventsViewState, cols?: number): string[] {
if (state.heatmapCursor === undefined) return [];
const trans = t();
const events = state.heatmapDay ?? [];
const heading = fmt(trans.calendar.heatmap.dayCount, {
date: state.heatmapCursor,
count: events.length,
sep: glyph.sep(),
});
return [
'',
...wrappedIndentedLines(heading, cols, type.heading),
...(events.length === 0
? wrappedIndentedLines(trans.calendar.heatmap.dayEmpty, cols, type.hint)
: events.flatMap((event) =>
wrappedIndentedLines(
`${eventWhen(event)} ${glyph.sep()} ${event.title}`,
cols,
type.body,
),
)),
];
}

function renderHubBody(
state: EventsViewState,
now: Date,
Expand Down Expand Up @@ -134,7 +167,10 @@ export function renderEvents(
return [
...notice,
...(state.heatmapBuckets && state.heatmapBuckets.length > 0
? heatmapLines(state.heatmapBuckets, now, cols)
? [
...heatmapLines(state.heatmapBuckets, now, cols, state.heatmapCursor),
...heatmapDayLines(state, cols),
]
: emptyStateLines('calendar', trans.calendar.noEvents, cols, bodyRows - notice.length)),
];
case 'list': {
Expand Down
102 changes: 78 additions & 24 deletions src/app/views/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,48 +173,102 @@ describe('eventsView detail screen', () => {
});

describe('eventsView heatmap navigation', () => {
it('selecting the heatmap hub option shows the grid, and any key (or Esc) returns to the hub', async () => {
const UP = '\x1b[A';
const DOWN = '\x1b[B';
const LEFT = '\x1b[D';

async function openHeatmap() {
const ctx = fakeCtx();
await eventsView.load(ctx);

for (let i = 0; i < 5; i++) eventsView.handleKey('\x1b[B', ctx);
for (let i = 0; i < 5; i++) eventsView.handleKey(DOWN, ctx);
eventsView.handleKey('\r', ctx);
return ctx;
}

function dayOf(date: Date): string {
const pad = (value: number) => String(value).padStart(2, '0');
return `${String(date.getFullYear())}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}

function daysAgo(days: number): Date {
const date = new Date();
date.setDate(date.getDate() - days);
return date;
}

function dayEvent(title: string, date: Date) {
const start = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 10);
return {
uid: title,
title,
start,
end: new Date(start.getTime() + 3_600_000),
isAllDay: false,
};
}

it('shows the grid with today selected, and Esc returns to the hub', async () => {
const ctx = await openHeatmap();
let out = stripAnsi(eventsView.render(ctx).join('\n'));
expect(out).toContain(t().calendar.heatmap.title);
expect(out).toContain(t().calendar.heatmap.legendLess);
expect(out).toContain(dayOf(new Date()));
expect(out).toContain(t().calendar.heatmap.dayEmpty);

expect(out).not.toContain(t().calendar.search);

eventsView.handleKey('x', ctx);
expect(eventsView.handleBack()).toBe(true);
out = stripAnsi(eventsView.render(ctx).join('\n'));
expect(out).toContain(t().calendar.search);
expect(out).not.toContain(t().calendar.heatmap.legendLess);
});

it('handleBack() (Esc) also returns from heatmap mode to the hub', async () => {
const ctx = fakeCtx();
await eventsView.load(ctx);
for (let i = 0; i < 5; i++) eventsView.handleKey('\x1b[B', ctx);
eventsView.handleKey('\r', ctx);
it("moves one day with up and one week with left, and lists that day's events", async () => {
const ctx = await openHeatmap();
calendarInRange.mockImplementation((start: Date) =>
dayOf(start) === dayOf(daysAgo(8)) ? [dayEvent('Retro', daysAgo(8))] : [],
);

expect(eventsView.handleBack()).toBe(true);
eventsView.handleKey(LEFT, ctx);
eventsView.handleKey(UP, ctx);
const out = stripAnsi(eventsView.render(ctx).join('\n'));
expect(out).toContain(t().calendar.search);

expect(out).toContain(dayOf(daysAgo(8)));
expect(out).toContain('1 events');
expect(out).toContain('Retro');
});

it('footerHint drops move/open in heatmap mode (any key returns to the hub, there is no field)', async () => {
const ctx = fakeCtx();
await eventsView.load(ctx);
for (let i = 0; i < 5; i++) eventsView.handleKey('\x1b[B', ctx);
it('does not move past today or off the oldest visible week', async () => {
const ctx = await openHeatmap();
eventsView.handleKey(DOWN, ctx);
expect(stripAnsi(eventsView.render(ctx).join('\n'))).toContain(dayOf(new Date()));

for (let i = 0; i < 80; i++) eventsView.handleKey(LEFT, ctx);
const first = /\d{4}-\d{2}-\d{2}/.exec(stripAnsi(eventsView.render(ctx).join('\n')))?.[0];
eventsView.handleKey(LEFT, ctx);
const second = /\d{4}-\d{2}-\d{2}/.exec(stripAnsi(eventsView.render(ctx).join('\n')))?.[0];

expect(second).toBe(first);
});

it('opens the selected day as a list with Enter, only when it has events', async () => {
const ctx = await openHeatmap();
eventsView.handleKey('\r', ctx);
expect(stripAnsi(eventsView.render(ctx).join('\n'))).toContain(t().calendar.heatmap.legendLess);

const hint = eventsView.footerHint(5, 80);
expect(hint).toBeDefined();
expect(hint).not.toContain(t().menu.hintMove);
expect(hint).not.toContain(t().menu.hintOpen);
expect(hint).toContain('1-5');
expect(hint).toContain(t().menu.hintQuit);
calendarInRange.mockImplementation((start: Date) =>
dayOf(start) === dayOf(new Date()) ? [dayEvent('Today meeting', new Date())] : [],
);
eventsView.handleKey(UP, ctx);
eventsView.handleKey(DOWN, ctx);
eventsView.handleKey('\r', ctx);

const out = stripAnsi(eventsView.render(ctx).join('\n'));
expect(out).toContain('Today meeting');
expect(out).not.toContain(t().calendar.heatmap.legendLess);
});

it('offers move and open in the footer while the heatmap is selectable', async () => {
await openHeatmap();
expect(eventsView.footerHint(5, 120)).toBeUndefined();
});
});

Expand Down Expand Up @@ -318,7 +372,7 @@ describe('eventsView navigation', () => {
expect(screen(ctx).join('\n')).toContain(t().common.offline);
});

it('offers the activity item only when the hub has no room for the heatmap', async () => {
it('replaces the activity item with a day-by-day entry when the hub already shows the heatmap', async () => {
const ctx = fakeCtx();
await eventsView.load(ctx);
expect(screen(ctx).join('\n')).toContain(t().calendar.heatmap.title);
Expand Down
61 changes: 52 additions & 9 deletions src/app/views/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
toDisplayEvent,
yearHeatmap,
} from '../../features/calendar.js';
import { heatmapRange, shiftHeatmapDay } from '../../features/calendar-heatmap.js';
import {
currentEvents,
hasEnded,
Expand All @@ -34,22 +35,27 @@ let currentList: CalendarEvent[] = [];
let listState: EventsViewState | undefined;
let hubField: ListField | undefined;
let hubValues: string[] = [];
let hubHeatmapInline = false;

function backLabel(): string {
return t().common.back;
}

function buildHubField(selected: string | undefined, withHeatmap: boolean): ListField {
function buildHubField(selected: string | undefined, heatmapInline: boolean): ListField {
const trans = t();
const options = [
{ value: 'upcoming', label: trans.calendar.next30Days },
{ value: 'week', label: trans.calendar.thisWeek },
{ value: 'month', label: trans.calendar.thisMonth },
{ value: 'search', label: trans.calendar.search },
{ value: 'past', label: trans.calendar.pastEvents },
...(withHeatmap ? [{ value: 'heatmap', label: trans.calendar.heatmap.title }] : []),
{
value: 'heatmap',
label: heatmapInline ? trans.calendar.heatmap.inspect : trans.calendar.heatmap.title,
},
];
hubValues = options.map((option) => option.value);
hubHeatmapInline = heatmapInline;
const initialIndex = Math.max(0, selected === undefined ? 0 : hubValues.indexOf(selected));
return new ListField({ title: trans.calendar.browse, options, initialIndex });
}
Expand All @@ -60,9 +66,9 @@ function selectedHubValue(): string | undefined {

function syncHubField(bodyRows: number): void {
if (state.mode !== 'hub') return;
const withHeatmap = !hubShowsHeatmap(bodyRows, state.heatmapBuckets);
if (hubValues.includes('heatmap') === withHeatmap) return;
hubField = buildHubField(selectedHubValue(), withHeatmap);
const heatmapInline = hubShowsHeatmap(bodyRows, state.heatmapBuckets);
if (hubHeatmapInline === heatmapInline) return;
hubField = buildHubField(selectedHubValue(), heatmapInline);
state = { ...state, hubField };
}

Expand Down Expand Up @@ -106,12 +112,42 @@ function showList(

const RECENT_EVENTS_CAP = 5;

function eventsOnDay(date: string): CalendarEvent[] {
if (!calendar) return [];
const [year = 0, month = 1, day = 1] = date.split('-').map(Number);
return calendar.inRange(new Date(year, month - 1, day), new Date(year, month - 1, day + 1));
}

function selectHeatmapDay(date: string): void {
state = {
...state,
heatmapCursor: date,
heatmapDay: eventsOnDay(date).map(toDisplayEvent),
};
}

const HEATMAP_STEPS: Record<string, number> = {
'\x1b[A': -1,
'\x1b[B': 1,
'\x1b[D': -7,
'\x1b[C': 7,
};

function moveHeatmapCursor(key: string, cols: number): void {
const step = HEATMAP_STEPS[key];
const now = new Date();
const { start, end } = heatmapRange(now, cols);
const current = state.heatmapCursor ?? end;
if (step === undefined) return;
const next = shiftHeatmapDay(current, step);
selectHeatmapDay(next < start ? start : next > end ? end : next);
}

function goToHub(): void {
const now = new Date();
const upcoming = calendar ? currentEvents(calendar, now) : [];
const nextEvent = upcoming.find((event) => event.start >= now);
const withHeatmap = hubValues.length === 0 || hubValues.includes('heatmap');
hubField = buildHubField(selectedHubValue(), withHeatmap);
hubField = buildHubField(selectedHubValue(), hubHeatmapInline);
state = {
mode: 'hub',
hubField,
Expand Down Expand Up @@ -201,7 +237,7 @@ export const eventsView = {

footerHint(tabCount: number, cols = Number.POSITIVE_INFINITY): string | undefined {
if (state.mode === 'search') return captureFooterHint(cols);
const passive = state.mode === 'loading' || state.mode === 'heatmap';
const passive = state.mode === 'loading';
return passive ? passiveFooterHint(tabCount, cols) : undefined;
},

Expand Down Expand Up @@ -268,6 +304,7 @@ export const eventsView = {
}
if (result.selected === 'heatmap') {
state = { ...state, mode: 'heatmap' };
selectHeatmapDay(heatmapRange(now, ctx.size.cols).end);
return;
}
if (result.selected === 'search') {
Expand All @@ -284,7 +321,13 @@ export const eventsView = {
return;
}
case 'heatmap': {
goToHub();
if (key === '\r' && state.heatmapCursor !== undefined) {
const date = state.heatmapCursor;
const events = eventsOnDay(date);
if (events.length > 0) showList(ctx, date, events, t().calendar.noEvents);
return;
}
moveHeatmapCursor(key, ctx.size.cols);
return;
}
case 'list': {
Expand Down
Loading
Loading