diff --git a/src/app/views/events-render.ts b/src/app/views/events-render.ts index 1ec32e1..7556d75 100644 --- a/src/app/views/events-render.ts +++ b/src/app/views/events-render.ts @@ -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'; @@ -24,6 +25,8 @@ export interface EventsViewState { stale?: boolean; nextEvent?: Event; heatmapBuckets?: HeatmapBucket[]; + heatmapCursor?: string; + heatmapDay?: Event[]; recentEvents?: Event[]; hubField?: ListField; listField?: ListField; @@ -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, @@ -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': { diff --git a/src/app/views/events.test.ts b/src/app/views/events.test.ts index 3872eca..c852834 100644 --- a/src/app/views/events.test.ts +++ b/src/app/views/events.test.ts @@ -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(); }); }); @@ -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); diff --git a/src/app/views/events.ts b/src/app/views/events.ts index 149e9fb..2464664 100644 --- a/src/app/views/events.ts +++ b/src/app/views/events.ts @@ -18,6 +18,7 @@ import { toDisplayEvent, yearHeatmap, } from '../../features/calendar.js'; +import { heatmapRange, shiftHeatmapDay } from '../../features/calendar-heatmap.js'; import { currentEvents, hasEnded, @@ -34,12 +35,13 @@ 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 }, @@ -47,9 +49,13 @@ function buildHubField(selected: string | undefined, withHeatmap: boolean): List { 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 }); } @@ -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 }; } @@ -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 = { + '\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, @@ -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; }, @@ -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') { @@ -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': { diff --git a/src/app/views/home.test.ts b/src/app/views/home.test.ts index 4a79dde..fcd08a0 100644 --- a/src/app/views/home.test.ts +++ b/src/app/views/home.test.ts @@ -359,6 +359,115 @@ describe('renderHome adaptive event count', () => { }); }); +describe('renderHome omitted events', () => { + const eventLines = Array.from( + { length: 4 }, + (_, i) => ` 07-${String(17 + i)} Event ${String(i)}`, + ); + + it('says how many events are not shown', () => { + const lines = renderHome( + { schedule: scheduleWith(), eventLines, loading: false }, + noon, + 10, + 80, + ); + const text = lines.map(stripAnsi).join('\n'); + const shown = eventLines.filter((line) => text.includes(line.trim())).length; + + expect(shown).toBeLessThan(eventLines.length); + expect(text).toContain(`${String(eventLines.length - shown)} more`); + expect(lines.length).toBeLessThanOrEqual(10); + }); + + it('does not mention omitted events when all of them fit', () => { + const text = renderHome({ schedule: scheduleWith(), eventLines, loading: false }, noon, 60, 80) + .map(stripAnsi) + .join('\n'); + + expect(text).not.toContain('more'); + }); + + it('keeps the first event reachable when a single wrapped event does not fit the remaining rows', () => { + const lines = renderHome( + { eventLines: [` 08-03 Community planning workshop`, ...eventLines], loading: false }, + noon, + 6, + 20, + ); + const text = lines.map(stripAnsi).join('').replace(/\s/g, ''); + + expect(text).toContain('Communityplanningworkshop'); + expect(text).toContain('2more'); + }); +}); + +describe('renderHome next event without a timetable', () => { + const event: CalendarModule.Event = { + title: 'Weekly meeting', + date: '07-16', + time: '19:00', + location: 'Room 1', + description: '', + startDate: campusDateTime('2026-07-16', '19:00'), + recurring: false, + uid: 'weekly', + }; + + it('shows the countdown above the events when there is room', () => { + const text = renderHome( + { + schedule: null, + nextEvent: event, + eventLines: [' 07-16 Weekly meeting'], + loading: false, + }, + noon, + 24, + 80, + ) + .map(stripAnsi) + .join('\n'); + + expect(text).toContain('Weekly meeting'); + expect(text).toMatch(/Next.*in 1d 7h/); + }); + + it('drops the countdown before it can push the first event out of view', () => { + const lines = renderHome( + { + schedule: null, + nextEvent: event, + eventLines: [' 07-16 Weekly meeting'], + loading: false, + }, + noon, + 6, + 80, + ).map(stripAnsi); + + expect(lines.filter((line) => line.includes('Weekly meeting'))).toHaveLength(1); + }); + + it('does not repeat the countdown when a timetable is present', () => { + const text = renderHome( + { + schedule: scheduleWith(), + nextEvent: event, + eventLines: [' 07-16 Weekly meeting'], + loading: false, + }, + noon, + 40, + 80, + ) + .map(stripAnsi) + .join('\n'); + + expect(text).not.toMatch(/Next\s+·\s+Weekly meeting/); + }); +}); + describe('renderHome day-progress bar', () => { it('shows a half-filled bar and 50% at noon', () => { process.env['NBTCA_ICON_MODE'] = 'ascii'; diff --git a/src/app/views/home.ts b/src/app/views/home.ts index 32b8c8f..b134b99 100644 --- a/src/app/views/home.ts +++ b/src/app/views/home.ts @@ -15,6 +15,8 @@ import { peekCalendar, toDisplayEvent, renderEventBrief, + renderCountdownBanner, + type Event, } from '../../features/calendar.js'; import { weekdayShortLabel } from '../../features/schedule-render.js'; import type { View, AppContext } from '../view.js'; @@ -31,6 +33,7 @@ export interface HomeData { loading?: boolean; schedule?: CachedSchedule | null; eventLines?: string[]; + nextEvent?: Event; eventsLoadFailed?: boolean; weekAhead?: { classDays: boolean[]; eventDays?: boolean[] }; } @@ -194,21 +197,45 @@ export function renderHome(data: HomeData, now: Date, bodyRows = 100, cols = 80) lines.push(''); } - lines.push(...panelHeading(trans.menu.events, cols)); + const eventsHeading = panelHeading(trans.menu.events, cols); + const firstEventRows = wrappedRenderedLines(data.eventLines?.[0] ?? '', cols).length; + const banner = + !data.schedule && data.nextEvent + ? [...renderCountdownBanner(data.nextEvent, now, cols).split('\n'), ''] + : []; + // Only spend rows on the countdown when the first event still fits below it + if ( + banner.length > 0 && + lines.length + banner.length + eventsHeading.length + firstEventRows <= bodyRows + ) { + lines.push(...banner); + } + + lines.push(...eventsHeading); if (data.eventLines && data.eventLines.length > 0) { const remaining = Number.isFinite(bodyRows) ? Math.max(0, Math.floor(bodyRows) - lines.length) : Number.POSITIVE_INFINITY; - let usedRows = 0; - for (const line of data.eventLines) { - const wrapped = wrappedRenderedLines(line, cols); - if (usedRows + wrapped.length > remaining) { - if (usedRows === 0 && remaining === 0) lines.push(...wrapped); - break; - } - lines.push(...wrapped); - usedRows += wrapped.length; + const rendered = data.eventLines.map((line) => wrappedRenderedLines(line, cols)); + const moreLine = (shown: number): string[] => + shown < rendered.length + ? wrappedIndentedLines( + fmt(trans.calendar.moreEvents, { count: rendered.length - shown }), + cols, + type.hint, + ) + : []; + let shown = 0; + let used = 0; + while (shown < rendered.length) { + const next = rendered[shown] ?? []; + const reserve = shown + 1 < rendered.length ? moreLine(shown + 1).length : 0; + if (used + next.length + reserve > remaining) break; + used += next.length; + shown += 1; } + if (shown === 0) shown = 1; + lines.push(...rendered.slice(0, shown).flat(), ...moreLine(shown)); } else if (data.loading) { lines.push(...loadingLines(trans.common.loading, cols)); } else if (data.eventsLoadFailed) { @@ -227,12 +254,15 @@ const HOME_EVENT_FETCH_CAP = 15; function calendarSnapshot( cal: Calendar, weekAhead: WeekAheadInfo | null, -): Pick { +): Pick { const now = new Date(); - const eventLines = currentEvents(cal, now) + const current = currentEvents(cal, now); + const eventLines = current .slice(0, HOME_EVENT_FETCH_CAP) .map((event) => renderEventBrief(toDisplayEvent(event), now)); - if (!weekAhead) return { eventLines }; + const upcoming = current.find((event) => event.start >= now); + const nextEvent = upcoming ? { nextEvent: toDisplayEvent(upcoming) } : {}; + if (!weekAhead) return { eventLines, ...nextEvent }; const { weekStart } = weekAhead; const monday = campusDateTime(weekStart, '00:00'); const daySet = new Set( @@ -248,6 +278,7 @@ function calendarSnapshot( ); return { eventLines, + ...nextEvent, weekAhead: { classDays: weekAhead.classDays, eventDays: WEEKDAYS.map((weekday) => daySet.has(weekday)), diff --git a/src/core/logo.test.ts b/src/core/logo.test.ts index 493a185..72dab6d 100644 --- a/src/core/logo.test.ts +++ b/src/core/logo.test.ts @@ -95,6 +95,28 @@ describe('buildLogoLines display capabilities', () => { expect(lines.join('')).toContain('@nbtca/prompt'); }); + it('draws the same emblem as the Unicode badge when an ASCII terminal has room', () => { + process.env['NBTCA_ICON_MODE'] = 'ascii'; + process.stdout.columns = 80; + process.stdout.rows = 24; + resetIconCache(); + + const lines = buildLogoLines().map(stripAnsi); + expect(lines.join('\n')).toContain('(@)'); + expect(lines.join('')).not.toContain('|\\ |'); + }); + + it('falls back from the ASCII emblem to the wordmark when the terminal is too short', () => { + process.env['NBTCA_ICON_MODE'] = 'ascii'; + process.stdout.columns = 80; + process.stdout.rows = 10; + resetIconCache(); + + const lines = buildLogoLines().map(stripAnsi); + expect(lines.join('\n')).not.toContain('(@)'); + expect(lines.join('\n')).toContain('|\\ |'); + }); + it('uses a readable wordmark when the ASCII artwork is too wide', () => { process.env['NBTCA_ICON_MODE'] = 'ascii'; process.stdout.columns = 20; diff --git a/src/core/logo.ts b/src/core/logo.ts index 3dcd139..96c06cd 100644 --- a/src/core/logo.ts +++ b/src/core/logo.ts @@ -48,6 +48,7 @@ function logoContent(): { art: string; metadata: string[] } | undefined { (tier) => tier.file, ) : []), + ...(useUnicodeIcons() ? [] : ['ascii-emblem.txt']), 'ascii-logo.txt', undefined, ]; diff --git a/src/features/calendar-heatmap.ts b/src/features/calendar-heatmap.ts index d53ff06..e50fce5 100644 --- a/src/features/calendar-heatmap.ts +++ b/src/features/calendar-heatmap.ts @@ -1,6 +1,7 @@ import type { HeatmapBucket } from '@nbtca/nbtcal'; +import chalk from 'chalk'; import { pickIcon } from '../core/icons.js'; -import { bodyWidth, c, space, type } from '../core/theme.js'; +import { bodyWidth, c, glyph, space, type } from '../core/theme.js'; import { t, getCurrentLanguage } from '../i18n/index.js'; import { visualWidth } from '../core/text.js'; @@ -61,29 +62,57 @@ function placeMonthLabels( return line; } +const DAY_MS = 86_400_000; + +function isoDate(ms: number): string { + const d = new Date(ms); + return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`; +} + +function gridGeometry(today: Date, cols: number) { + const cellWidth = cols < GRID_PREFIX_WIDTH + MAX_WEEK_COLUMNS * 2 ? 1 : 2; + const availableColumns = Math.floor((cols - GRID_PREFIX_WIDTH) / cellWidth); + const numCols = Math.max(1, Math.min(MAX_WEEK_COLUMNS, availableColumns)); + const todayMs = Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()); + const gridEndMs = todayMs + (6 - utcDayToMonIndex(new Date(todayMs).getUTCDay())) * DAY_MS; + return { cellWidth, numCols, todayMs, gridStartMs: gridEndMs - (numCols * 7 - 1) * DAY_MS }; +} + +/** First and last selectable day of the grid drawn at this width; days after today are not selectable. */ +export function heatmapRange( + today: Date, + cols = Number.POSITIVE_INFINITY, +): { start: string; end: string } { + const { gridStartMs, todayMs } = gridGeometry(today, bodyWidth(cols)); + return { start: isoDate(gridStartMs), end: isoDate(todayMs) }; +} + +export function shiftHeatmapDay(date: string, days: number): string { + return isoDate(parseBucketDate(date).getTime() + days * DAY_MS); +} + +function cursorCell(count: number, useColor: boolean): string { + if (!useColor || chalk.level === 0) return glyph.dot(); + const level = Math.max(0, Math.min(count, LEVEL_GLYPHS.length - 1)); + const [unicode, ascii] = LEVEL_GLYPHS[level] ?? LEVEL_GLYPHS[0]; + return type.cursor(pickIcon(unicode, ascii)); +} + export function renderHeatmap( buckets: HeatmapBucket[], today: Date, - options?: { color?: boolean; cols?: number }, + options?: { color?: boolean; cols?: number; cursor?: string }, ): string { const useColor = options?.color === true; const trans = t(); const cols = bodyWidth(options?.cols ?? Number.POSITIVE_INFINITY); - const cellWidth = cols < GRID_PREFIX_WIDTH + MAX_WEEK_COLUMNS * 2 ? 1 : 2; - const availableColumns = Math.floor((cols - GRID_PREFIX_WIDTH) / cellWidth); - const numCols = Math.max(1, Math.min(MAX_WEEK_COLUMNS, availableColumns)); + const { cellWidth, numCols, gridStartMs } = gridGeometry(today, cols); const countByDate = new Map(); for (const b of buckets) { countByDate.set(b.date, b.count); } - const todayProxy = new Date(Date.UTC(today.getFullYear(), today.getMonth(), today.getDate())); - - const todayMonIndex = utcDayToMonIndex(todayProxy.getUTCDay()); - const gridEndMs = todayProxy.getTime() + (6 - todayMonIndex) * 86400000; // Sunday of today's week - - const gridStartMs = gridEndMs - (numCols * 7 - 1) * 86400000; const gridStart = new Date(gridStartMs); type Cell = { date: string; count: number } | null; @@ -156,7 +185,9 @@ export function renderHeatmap( const cells = columns.map((col) => { const cell = col[row]; if (cell === null || cell === undefined) return ' '; - return levelCell(cell.count, useColor); + return cell.date === options?.cursor + ? cursorCell(cell.count, useColor) + : levelCell(cell.count, useColor); }); lines.push(`${space.indent}${wdLabel} ${cells.join(cellWidth === 2 ? ' ' : '')}`); } diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 39290db..d827ae5 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -89,6 +89,9 @@ export interface Translations { title: string; legendLess: string; legendMore: string; + inspect: string; + dayCount: string; + dayEmpty: string; }; pastLoading: string; pastEvents: string; @@ -97,6 +100,7 @@ export interface Translations { viewPastDetail: string; next: string; recentActivity: string; + moreEvents: string; browse: string; next30Days: string; startingNow: string; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 825f5c8..21b6ee6 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -75,7 +75,10 @@ "heatmap": { "title": "Activity (Last 12 Months)", "legendLess": "Less", - "legendMore": "More" + "legendMore": "More", + "inspect": "Inspect activity by day", + "dayCount": "{date} {sep} {count} events", + "dayEmpty": "No events that day" }, "pastLoading": "Loading past events...", "pastEvents": "Past Events", @@ -84,6 +87,7 @@ "viewPastDetail": "Select an event for details:", "next": "Next", "recentActivity": "Coming up", + "moreEvents": "{count} more", "browse": "Browse", "next30Days": "Next 30 Days", "inPrefix": "in", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index a9ed1a5..6f65eb5 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -75,7 +75,10 @@ "heatmap": { "title": "近一年活跃度", "legendLess": "少", - "legendMore": "多" + "legendMore": "多", + "inspect": "逐日查看活跃度", + "dayCount": "{date} · {count} 场活动", + "dayEmpty": "这一天没有活动" }, "pastLoading": "正在加载历史活动...", "pastEvents": "历史活动", @@ -84,6 +87,7 @@ "viewPastDetail": "选择活动查看详情:", "next": "下一场", "recentActivity": "近期安排", + "moreEvents": "还有 {count} 项", "browse": "浏览", "next30Days": "近 30 天", "inPrefix": "还有", diff --git a/src/logo/ascii-emblem.txt b/src/logo/ascii-emblem.txt new file mode 100644 index 0000000..4f65848 --- /dev/null +++ b/src/logo/ascii-emblem.txt @@ -0,0 +1,11 @@ + _.-''''''-._ + .-' '-. + .' .-. .-. '. + / (@) (@) \ +| '-' '-' | +| | +| \ / | + \ '. .' / + '. '-........-' .' + '-._ _.-' + ''-......-''