diff --git a/src/mcp.test.ts b/src/mcp.test.ts index b63e49e..f01a8c2 100644 --- a/src/mcp.test.ts +++ b/src/mcp.test.ts @@ -7,6 +7,7 @@ const servers: Array> = []; const clients: Client[] = []; afterEach(async () => { + vi.useRealTimers(); await Promise.all(clients.splice(0).map((client) => client.close())); await Promise.all(servers.splice(0).map((server) => server.close())); }); @@ -82,6 +83,75 @@ describe('Pirsch MCP tool contracts', () => { expect(get).toHaveBeenCalledWith('/options/event', 'default-domain', {}); }); + it('resolves named comparison periods in the requested timezone', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-01T00:30:00.000Z')); + const get = vi.fn().mockResolvedValue({ visitors: 1 }); + const client = await connect(() => ({ listDomains: vi.fn(), get })); + + const result = await client.callTool({ + name: 'pirsch_compare_periods', + arguments: { period: 'today', timezone: 'America/Los_Angeles' }, + }); + + expect(result.isError).toBeUndefined(); + expect(get.mock.calls[0]).toEqual(['/statistics/total', 'default-domain', expect.objectContaining({ + from: '2026-07-31', + to: '2026-07-31', + timezone: 'America/Los_Angeles', + })]); + }); + + it('uses the timezone-local weekday for named weekly periods', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-03T00:30:00.000Z')); + const get = vi.fn().mockResolvedValue({ visitors: 1 }); + const client = await connect(() => ({ listDomains: vi.fn(), get })); + + const result = await client.callTool({ + name: 'pirsch_compare_periods', + arguments: { period: 'week', timezone: 'America/Los_Angeles' }, + }); + + expect(result.isError).toBeUndefined(); + expect(get.mock.calls[0]).toEqual(['/statistics/total', 'default-domain', expect.objectContaining({ + from: '2026-07-27', + to: '2026-08-02', + })]); + }); + + it('resolves named comparison periods in the configured timezone', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-01T00:30:00.000Z')); + const get = vi.fn().mockResolvedValue({ visitors: 1 }); + const client = await connectOptions({ + clientFactory: () => ({ listDomains: vi.fn(), get }), + clientOptions: { timezone: 'America/Los_Angeles' }, + defaultDomainId: 'default-domain', + }); + + const result = await client.callTool({ name: 'pirsch_compare_periods', arguments: { period: 'today' } }); + + expect(result.isError).toBeUndefined(); + expect(get.mock.calls[0]).toEqual(['/statistics/total', 'default-domain', expect.objectContaining({ + from: '2026-07-31', + to: '2026-07-31', + })]); + }); + + it('rejects reverse clocks after a named same-day period is resolved', async () => { + const get = vi.fn(); + const client = await connect(() => ({ listDomains: vi.fn(), get })); + + const result = await client.callTool({ + name: 'pirsch_compare_periods', + arguments: { period: 'today', fromTime: '18:00', toTime: '09:00' }, + }); + + expect(result.isError).toBe(true); + expect(get).not.toHaveBeenCalled(); + }); + it('keeps the environment timezone when custom client options are supplied', async () => { const originalTimezone = process.env.PIRSCH_TIMEZONE; process.env.PIRSCH_TIMEZONE = 'Europe/Berlin'; diff --git a/src/server.ts b/src/server.ts index fb03617..14d451d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -15,7 +15,7 @@ import { type StatisticsQuery, } from './schemas.js'; import type { PirschCredentials, PirschFilter, SafeDomain, StatisticsTotals, VisitorsPoint } from './types.js'; -import { getDateRange, isoDate, pctChange } from './utils.js'; +import { getDateRange, isoDate, pctChange, type PirschPeriod } from './utils.js'; export interface PirschReader { listDomains(): Promise; @@ -83,20 +83,30 @@ function previousRange(from: string, to: string): { from: string; to: string } { }; } -function resolveComparisonRanges(input: ComparisonQuery) { - if (input.from && input.to && input.compareFrom && input.compareTo) { - return { current: { from: input.from, to: input.to }, previous: { from: input.compareFrom, to: input.compareTo } }; +function validateResolvedClockRange(input: ComparisonQuery, range: { from: string; to: string }): void { + if (range.from === range.to && input.fromTime && input.toTime && input.fromTime > input.toTime) { + throw new Error('toTime must be on or after fromTime for a same-day range.'); } - if (input.period) { - const range = getDateRange(input.period); +} + +function resolveComparisonRanges(input: ComparisonQuery, timezone?: string) { + let ranges: { current: { from: string; to: string }; previous: { from: string; to: string } }; + if (input.from && input.to && input.compareFrom && input.compareTo) { + ranges = { current: { from: input.from, to: input.to }, previous: { from: input.compareFrom, to: input.compareTo } }; + } else if (input.period) { + const range = getDateRange(input.period as PirschPeriod, timezone); const current = { from: isoDate(range.start), to: isoDate(range.end) }; - return { current, previous: previousRange(current.from, current.to) }; + ranges = { current, previous: previousRange(current.from, current.to) }; + } else { + throw new Error('Provide period or from/to plus compareFrom/compareTo.'); } - throw new Error('Provide period or from/to plus compareFrom/compareTo.'); + validateResolvedClockRange(input, ranges.current); + validateResolvedClockRange(input, ranges.previous); + return ranges; } -async function comparePeriods(reader: PirschReader, domainId: string, input: ComparisonQuery) { - const { current, previous } = resolveComparisonRanges(input); +async function comparePeriods(reader: PirschReader, domainId: string, input: ComparisonQuery, configuredTimezone?: string) { + const { current, previous } = resolveComparisonRanges(input, input.timezone ?? configuredTimezone); const { domainId: _domainId, period: _period, compareFrom: _compareFrom, compareTo: _compareTo, ...filters } = input; const currentFilter = { ...filters, ...current }; const previousFilter = { ...filters, ...previous }; @@ -120,11 +130,12 @@ async function comparePeriods(reader: PirschReader, domainId: string, input: Com export function createPirschServer(options: PirschServerOptions = {}): McpServer { const defaultDomainId = options.defaultDomainId ?? process.env.PIRSCH_DEFAULT_DOMAIN_ID; + const configuredTimezone = options.clientOptions?.timezone ?? process.env.PIRSCH_TIMEZONE; let reader: PirschReader | undefined; const getReader = () => { reader ??= options.clientFactory?.() ?? new PirschClient( options.credentials ?? { clientId: process.env.PIRSCH_CLIENT_ID, clientSecret: process.env.PIRSCH_CLIENT_SECRET }, - { ...options.clientOptions, timezone: options.clientOptions?.timezone ?? process.env.PIRSCH_TIMEZONE } + { ...options.clientOptions, timezone: configuredTimezone } ); return reader; }; @@ -202,7 +213,7 @@ export function createPirschServer(options: PirschServerOptions = {}): McpServer async (input) => { try { const domainId = resolveDomain(input.domainId, defaultDomainId); - return jsonResult(await comparePeriods(getReader(), domainId, input)); + return jsonResult(await comparePeriods(getReader(), domainId, input, configuredTimezone)); } catch (error) { return errorResult(error); } diff --git a/src/utils.ts b/src/utils.ts index 04853f1..b893ec1 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,8 +1,20 @@ import type { VisitorsPoint } from './types.js'; -export function getDateRange(period: 'today' | 'yesterday' | 'week' | 'lastWeek' | 'month' | 'lastMonth') { - const now = new Date(); - const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); +export type PirschPeriod = 'today' | 'yesterday' | 'week' | 'lastWeek' | 'month' | 'lastMonth'; + +function dateInTimezone(now: Date, timezone: string): Date { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: timezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(now); + const value = Object.fromEntries(parts.filter((part) => part.type !== 'literal').map((part) => [part.type, part.value])); + return new Date(Date.UTC(Number(value.year), Number(value.month) - 1, Number(value.day))); +} + +export function getDateRange(period: PirschPeriod, timezone = 'UTC', now = new Date()) { + const start = dateInTimezone(now, timezone); const end = new Date(start); switch (period) { @@ -15,7 +27,7 @@ export function getDateRange(period: 'today' | 'yesterday' | 'week' | 'lastWeek' end.setUTCHours(23, 59, 59, 999); return { start, end }; case 'week': { - const day = now.getUTCDay(); + const day = start.getUTCDay(); start.setUTCDate(start.getUTCDate() - ((day + 6) % 7)); end.setTime(start.getTime()); end.setUTCDate(start.getUTCDate() + 6); @@ -23,7 +35,7 @@ export function getDateRange(period: 'today' | 'yesterday' | 'week' | 'lastWeek' return { start, end }; } case 'lastWeek': { - const day = now.getUTCDay(); + const day = start.getUTCDay(); start.setUTCDate(start.getUTCDate() - ((day + 6) % 7) - 7); end.setTime(start.getTime()); end.setUTCDate(start.getUTCDate() + 6);