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
39 changes: 39 additions & 0 deletions src/utils/dateFormat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,45 @@ function renderInZone(date: Date, timeZone: string, withTime: boolean): string |
}
}

/**
* One-line account date context: the resolved account timezone plus today's
* local date and weekday in that zone. Prepended to list responses so the model
* reasons about "this week", weekdays, and "now" against the account's actual
* zone instead of guessing (the wall-clock date it has from its own context is
* in an unknown zone, and nothing else in the payload states the account zone).
*
* `now` is injectable so callers/tests can pin the clock; production passes the
* real time. Falls back to a UTC-labelled reading when no usable zone is given.
*/
export function formatAccountDateContext(timeZone?: string, now: Date = new Date()): string {
const usableZone = timeZone && isValidTimeZone(timeZone) ? timeZone : undefined;
const zone = usableZone ?? 'UTC';
try {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: zone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
weekday: 'long'
})
.formatToParts(now)
.reduce<Record<string, string>>((acc, part) => {
acc[part.type] = part.value;
return acc;
}, {});

if (parts.year && parts.month && parts.day) {
const date = `${parts.year}-${parts.month}-${parts.day}`;
const weekday = parts.weekday ? ` (${parts.weekday})` : '';
const label = usableZone ?? 'unknown (times shown in UTC)';
return `Account timezone: ${label} | Today: ${date}${weekday}`;
}
} catch {
// fall through to a minimal UTC reading
}
return `Account timezone: ${usableZone ?? 'unknown (times shown in UTC)'} | Today: ${now.toISOString().slice(0, 10)}`;
}

/**
* Reduce an instant to its calendar date (YYYY-MM-DD) in a given zone.
*
Expand Down
11 changes: 7 additions & 4 deletions src/utils/responseFormatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { MotionProject, MotionTask, MotionWorkspace, MotionComment, MotionCustomField, MotionCustomFieldValue, MotionRecurringTask, MotionSchedule, MotionScheduleDetails, MotionStatus } from '../types/motion';
import { TruncationInfo } from '../types/mcp';
import { LIMITS } from './constants';
import { formatTimestamp, formatDateOnly } from './dateFormat';
import { formatTimestamp, formatDateOnly, formatAccountDateContext, resolveDisplayTimeZone } from './dateFormat';

const TRUNCATION_REASON_MESSAGES: Record<string, string> = {
page_size_limit: 'due to page size limits',
Expand Down Expand Up @@ -166,7 +166,7 @@ export function formatTaskList(
if (limit) title += ` (limit: ${limit})`;

const list = tasks.map(taskFormatter).join('\n');
let responseText = `${title}:\n${list}`;
let responseText = `${formatAccountDateContext(timeZone)}\n${title}:\n${list}`;
responseText += formatTruncationNotice(truncation);

const structured: Record<string, unknown> = {
Expand Down Expand Up @@ -540,8 +540,11 @@ export function formatScheduleList(schedules: MotionSchedule[]): CallToolResult

return `- ${name} (${timezone})${workingHours}`;
};

return formatListResponse(schedules, `Found ${schedules.length} schedule${schedules.length === 1 ? '' : 's'}`, scheduleFormatter);

const contextLine = formatAccountDateContext(resolveDisplayTimeZone(schedules));
const list = schedules.map(scheduleFormatter).join('\n');
const title = `Found ${schedules.length} schedule${schedules.length === 1 ? '' : 's'}`;
return formatMcpSuccess(`${contextLine}\n${title}:\n${list}`);
}

export function formatStatusList(statuses: MotionStatus[]): CallToolResult {
Expand Down
51 changes: 51 additions & 0 deletions tests/date-context-formatter.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, it, expect } from 'vitest';
import { formatAccountDateContext } from '../src/utils/dateFormat';
import { formatTaskList, formatScheduleList } from '../src/utils/responseFormatters';
import type { MotionSchedule, MotionTask } from '../src/types/motion';

function textOf(res: any): string {
return (res.content?.[0] as any)?.text || '';
}

describe('formatAccountDateContext', () => {
it('reports the account zone and local date/weekday for a pinned instant', () => {
// 2026-08-24T20:00:00Z = 14:00 MDT (UTC-6) on Monday Aug 24 in Denver.
const line = formatAccountDateContext('America/Denver', new Date('2026-08-24T20:00:00.000Z'));
expect(line).toBe('Account timezone: America/Denver | Today: 2026-08-24 (Monday)');
});

it('resolves the local calendar day, not the UTC day, near midnight', () => {
// 04:00Z is still 22:00 the previous day (Sunday Aug 23) in Denver.
const line = formatAccountDateContext('America/Denver', new Date('2026-08-24T04:00:00.000Z'));
expect(line).toContain('Today: 2026-08-23 (Sunday)');
});

it('falls back to a UTC-labelled reading when no zone is given', () => {
const line = formatAccountDateContext(undefined, new Date('2026-08-24T20:00:00.000Z'));
expect(line).toContain('unknown (times shown in UTC)');
expect(line).toContain('Today: 2026-08-24');
});

it('falls back when the zone is not a valid IANA id', () => {
const line = formatAccountDateContext('Not/AZone', new Date('2026-08-24T20:00:00.000Z'));
expect(line).toContain('unknown (times shown in UTC)');
});
});

describe('account context in list responses', () => {
it('prepends the account context line to task lists', () => {
const tasks = [{ id: 't1', name: 'A' }] as MotionTask[];
const text = textOf(formatTaskList(tasks, { timeZone: 'America/Denver' }));
expect(text).toContain('Account timezone: America/Denver | Today:');
expect(text.startsWith('Account timezone:')).toBe(true);
});

it('derives the account context zone from the schedules for schedule lists', () => {
const schedules = [
{ name: 'Work hours', isDefaultTimezone: true, timezone: 'America/Denver', schedule: {} },
] as MotionSchedule[];
const text = textOf(formatScheduleList(schedules));
expect(text).toContain('Account timezone: America/Denver | Today:');
expect(text).toContain('Work hours');
});
});
Loading