feat(timers): add timer CRUD endpoints for ChronoMind cloud sync (42 new tests, 759 total)

This commit is contained in:
saravanakumardb1 2026-02-27 23:48:31 -08:00
parent 89b6588e1d
commit c1a1f86cd6
6 changed files with 937 additions and 0 deletions

View File

@ -37,6 +37,8 @@ const CONTAINER_DEFS: Record<string, ContainerConfig> = {
// NomGap fasting modules
fasting_sessions: { partitionKeyPath: '/userId' },
fasting_protocols: { partitionKeyPath: '/userId' },
// ChronoMind timers
timers: { partitionKeyPath: '/userId' },
// Telemetry (client diagnostics — see docs/WINDSURF/CLIENT_TELEMETRY_DESIGN.md)
telemetry_events: { partitionKeyPath: '/pk', defaultTtl: 30 * 86400 },
telemetry_error_clusters: { partitionKeyPath: '/pk', defaultTtl: 90 * 86400 },

View File

@ -0,0 +1,191 @@
/**
* Timers repository Cosmos DB CRUD + sync + batch upsert.
*
* Container: timers (partition key: /userId)
*/
import { getContainer } from '../../lib/cosmos.js';
import type { TimerDoc, TimerQuery, BatchUpsertResult } from './types.js';
function container() {
return getContainer('timers');
}
export async function listTimers(
userId: string,
productId: string,
query: TimerQuery
): Promise<{ items: TimerDoc[]; total: number }> {
const conditions: string[] = ['c.userId = @userId', 'c.productId = @productId'];
const params: { name: string; value: string | number }[] = [
{ name: '@userId', value: userId },
{ name: '@productId', value: productId },
];
if (query.state) {
conditions.push('c.state = @state');
params.push({ name: '@state', value: query.state });
}
if (query.type) {
conditions.push('c.type = @type');
params.push({ name: '@type', value: query.type });
}
if (query.urgency) {
conditions.push('c.urgency = @urgency');
params.push({ name: '@urgency', value: query.urgency });
}
if (query.category) {
conditions.push('c.category = @category');
params.push({ name: '@category', value: query.category });
}
const where = `WHERE ${conditions.join(' AND ')}`;
const sortField = `c.${query.sortBy}`;
const orderDir = query.sortOrder.toUpperCase();
// Count query
const countResult = await container()
.items.query<number>({
query: `SELECT VALUE COUNT(1) FROM c ${where}`,
parameters: params,
})
.fetchAll();
const total = countResult.resources[0] ?? 0;
// Data query with pagination
const { resources } = await container()
.items.query<TimerDoc>({
query: `SELECT * FROM c ${where} ORDER BY ${sortField} ${orderDir} OFFSET @offset LIMIT @limit`,
parameters: [
...params,
{ name: '@offset', value: query.offset },
{ name: '@limit', value: query.limit },
],
})
.fetchAll();
return { items: resources, total };
}
export async function getTimer(id: string, userId: string): Promise<TimerDoc | null> {
try {
const { resource } = await container().item(id, userId).read<TimerDoc>();
return resource ?? null;
} catch {
return null;
}
}
export async function createTimer(doc: TimerDoc): Promise<TimerDoc> {
const { resource } = await container().items.create(doc);
return resource as TimerDoc;
}
export async function updateTimer(
id: string,
userId: string,
updates: Partial<TimerDoc>,
expectedSyncVersion: number
): Promise<{ doc: TimerDoc | null; conflict: boolean; serverVersion?: number }> {
try {
const { resource: existing } = await container().item(id, userId).read<TimerDoc>();
if (!existing) return { doc: null, conflict: false };
// Optimistic concurrency: reject stale writes
if (expectedSyncVersion <= existing.syncVersion) {
return { doc: null, conflict: true, serverVersion: existing.syncVersion };
}
const now = new Date().toISOString();
const merged: TimerDoc = {
...existing,
...updates,
syncVersion: expectedSyncVersion,
lastSyncedAt: now,
};
const { resource } = await container().item(id, userId).replace(merged);
return { doc: resource as TimerDoc, conflict: false };
} catch {
return { doc: null, conflict: false };
}
}
export async function deleteTimer(id: string, userId: string): Promise<boolean> {
try {
const { resource: existing } = await container().item(id, userId).read<TimerDoc>();
if (!existing) return false;
await container().item(id, userId).delete();
return true;
} catch {
return false;
}
}
export async function getTimersSince(
userId: string,
productId: string,
sinceTimestamp: string,
limit: number
): Promise<TimerDoc[]> {
const { resources } = await container()
.items.query<TimerDoc>({
query:
'SELECT * FROM c WHERE c.userId = @userId AND c.productId = @productId AND c.lastSyncedAt >= @since ORDER BY c.lastSyncedAt ASC OFFSET 0 LIMIT @limit',
parameters: [
{ name: '@userId', value: userId },
{ name: '@productId', value: productId },
{ name: '@since', value: sinceTimestamp },
{ name: '@limit', value: limit },
],
})
.fetchAll();
return resources;
}
export async function batchUpsert(
userId: string,
productId: string,
timers: Array<Record<string, unknown> & { id: string; syncVersion: number }>
): Promise<BatchUpsertResult> {
const synced: string[] = [];
const conflicts: Array<{ id: string; serverVersion: number }> = [];
const errors: Array<{ id: string; error: string }> = [];
for (const timer of timers) {
try {
const existing = await getTimer(timer.id, userId);
const now = new Date().toISOString();
if (existing) {
// Upsert: accept if incoming syncVersion >= existing
if (timer.syncVersion >= existing.syncVersion) {
const merged: TimerDoc = {
...existing,
...timer,
userId,
productId,
lastSyncedAt: now,
};
await container().item(timer.id, userId).replace(merged);
synced.push(timer.id);
} else {
conflicts.push({ id: timer.id, serverVersion: existing.syncVersion });
}
} else {
// New document
const doc: TimerDoc = {
...timer,
userId,
productId,
lastSyncedAt: now,
} as TimerDoc;
await container().items.create(doc);
synced.push(timer.id);
}
} catch (err) {
errors.push({ id: timer.id, error: err instanceof Error ? err.message : 'Unknown error' });
}
}
return { synced, conflicts, errors };
}

View File

@ -0,0 +1,158 @@
/**
* Timer REST endpoints ChronoMind cloud sync.
*
* GET /timers list user's timers (filterable, paginated)
* GET /timers/sync delta sync (timers modified since timestamp)
* GET /timers/:id single timer
* POST /timers create timer
* PUT /timers/:id update timer (with syncVersion conflict check)
* DELETE /timers/:id delete timer
* POST /timers/batch batch upsert (offline queue flush / initial sync)
*/
import type { FastifyInstance } from 'fastify';
import { BadRequestError, NotFoundError, ConflictError } from '../../lib/errors.js';
import { extractAuth } from '../../lib/auth.js';
import * as repo from './repository.js';
import {
CreateTimerSchema,
UpdateTimerSchema,
TimerQuerySchema,
TimerSyncQuerySchema,
BatchUpsertSchema,
type TimerDoc,
} from './types.js';
const PRODUCT_ID = 'chronomind';
export async function timerRoutes(app: FastifyInstance) {
// Sync — must be before :id param route
app.get('/timers/sync', async req => {
const auth = await extractAuth(req);
const parsed = TimerSyncQuerySchema.safeParse(req.query);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const timers = await repo.getTimersSince(
auth.sub,
PRODUCT_ID,
parsed.data.since,
parsed.data.limit
);
return { timers, count: timers.length };
});
// List timers
app.get('/timers', async req => {
const auth = await extractAuth(req);
const parsed = TimerQuerySchema.safeParse(req.query);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const { items, total } = await repo.listTimers(auth.sub, PRODUCT_ID, parsed.data);
return { items, total, limit: parsed.data.limit, offset: parsed.data.offset };
});
// Get single timer
app.get('/timers/:id', async req => {
const auth = await extractAuth(req);
const { id } = req.params as { id: string };
const timer = await repo.getTimer(id, auth.sub);
if (!timer) throw new NotFoundError('Timer not found');
if (timer.productId !== PRODUCT_ID) throw new NotFoundError('Timer not found');
return timer;
});
// Create timer
app.post('/timers', async (req, reply) => {
const auth = await extractAuth(req);
const parsed = CreateTimerSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const input = parsed.data;
const now = new Date().toISOString();
const doc: TimerDoc = {
id: input.id,
userId: auth.sub,
productId: PRODUCT_ID,
label: input.label,
description: input.description,
type: input.type,
state: input.state,
urgency: input.urgency,
duration: input.duration,
targetTime: input.targetTime,
createdAt: now,
startedAt: input.startedAt,
cascade: input.cascade,
pomodoro: input.pomodoro,
isCalendarSync: input.isCalendarSync,
calendarEventId: input.calendarEventId,
category: input.category,
deviceId: input.deviceId,
lastSyncedAt: now,
syncVersion: input.syncVersion,
};
req.log.info({ timerId: doc.id, type: doc.type }, 'Creating timer');
const created = await repo.createTimer(doc);
reply.code(201);
return created;
});
// Update timer (with syncVersion conflict check)
app.put('/timers/:id', async req => {
const auth = await extractAuth(req);
const { id } = req.params as { id: string };
const parsed = UpdateTimerSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const { syncVersion, ...updates } = parsed.data;
const result = await repo.updateTimer(id, auth.sub, updates, syncVersion);
if (result.conflict) {
throw new ConflictError(
`Sync conflict: server version is ${result.serverVersion}, received ${syncVersion}`
);
}
if (!result.doc) throw new NotFoundError('Timer not found');
req.log.info({ timerId: id, syncVersion }, 'Updated timer');
return result.doc;
});
// Delete timer
app.delete('/timers/:id', async req => {
const auth = await extractAuth(req);
const { id } = req.params as { id: string };
const success = await repo.deleteTimer(id, auth.sub);
if (!success) throw new NotFoundError('Timer not found');
req.log.info({ timerId: id }, 'Deleted timer');
return { success: true };
});
// Batch upsert (initial sync / offline queue flush)
app.post('/timers/batch', async req => {
const auth = await extractAuth(req);
const parsed = BatchUpsertSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const now = new Date().toISOString();
const enriched = parsed.data.timers.map(t => ({
...t,
createdAt: now,
lastSyncedAt: now,
}));
req.log.info({ count: enriched.length }, 'Batch upsert timers');
const result = await repo.batchUpsert(auth.sub, PRODUCT_ID, enriched);
return result;
});
}

View File

@ -0,0 +1,408 @@
/**
* Timers module unit tests validates schemas, constants, and type guards.
*/
import { describe, it, expect } from 'vitest';
import {
CreateTimerSchema,
UpdateTimerSchema,
TimerQuerySchema,
TimerSyncQuerySchema,
BatchUpsertSchema,
TIMER_TYPES,
TIMER_STATES,
URGENCY_LEVELS,
CASCADE_PRESETS,
} from './types.js';
// ── Constants ──
describe('type constants', () => {
it('has 3 timer types', () => {
expect(TIMER_TYPES).toEqual(['countdown', 'alarm', 'pomodoro']);
});
it('has 7 timer states', () => {
expect(TIMER_STATES).toEqual([
'active',
'paused',
'fired',
'snoozed',
'dismissed',
'completed',
'warning',
]);
expect(TIMER_STATES).toHaveLength(7);
});
it('has 5 urgency levels', () => {
expect(URGENCY_LEVELS).toEqual(['critical', 'important', 'standard', 'gentle', 'passive']);
expect(URGENCY_LEVELS).toHaveLength(5);
});
it('has 4 cascade presets', () => {
expect(CASCADE_PRESETS).toEqual(['minimal', 'standard', 'aggressive', 'custom']);
});
});
// ── CreateTimerSchema ──
describe('CreateTimerSchema', () => {
const validMinimal = {
id: 'timer_001',
label: 'Morning alarm',
type: 'alarm',
duration: 0,
targetTime: '2026-03-01T07:00:00.000Z',
};
it('accepts minimal valid input with defaults', () => {
const result = CreateTimerSchema.safeParse(validMinimal);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.id).toBe('timer_001');
expect(result.data.label).toBe('Morning alarm');
expect(result.data.type).toBe('alarm');
expect(result.data.state).toBe('active');
expect(result.data.urgency).toBe('standard');
expect(result.data.syncVersion).toBe(1);
}
});
it('accepts full countdown timer with cascade', () => {
const result = CreateTimerSchema.safeParse({
...validMinimal,
type: 'countdown',
state: 'active',
urgency: 'critical',
duration: 300,
description: 'Important meeting prep',
cascade: {
preset: 'aggressive',
intervals: [30, 15, 5, 1],
},
deviceId: 'iphone-14-pro',
category: 'work',
syncVersion: 3,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.cascade?.preset).toBe('aggressive');
expect(result.data.cascade?.intervals).toEqual([30, 15, 5, 1]);
expect(result.data.urgency).toBe('critical');
expect(result.data.syncVersion).toBe(3);
}
});
it('accepts pomodoro timer with full config', () => {
const result = CreateTimerSchema.safeParse({
...validMinimal,
type: 'pomodoro',
pomodoro: {
focusMinutes: 25,
shortBreakMinutes: 5,
longBreakMinutes: 15,
roundsBeforeLong: 4,
currentRound: 1,
isBreak: false,
totalRoundsCompleted: 0,
},
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.pomodoro?.focusMinutes).toBe(25);
expect(result.data.pomodoro?.roundsBeforeLong).toBe(4);
}
});
it('accepts timer with calendar sync', () => {
const result = CreateTimerSchema.safeParse({
...validMinimal,
isCalendarSync: true,
calendarEventId: 'cal_abc123',
});
expect(result.success).toBe(true);
});
it('rejects missing id', () => {
const result = CreateTimerSchema.safeParse({
label: 'Morning alarm',
type: 'alarm',
duration: 0,
targetTime: '2026-03-01T07:00:00.000Z',
});
expect(result.success).toBe(false);
});
it('rejects missing label', () => {
const result = CreateTimerSchema.safeParse({
id: 'timer_001',
type: 'alarm',
duration: 0,
targetTime: '2026-03-01T07:00:00.000Z',
});
expect(result.success).toBe(false);
});
it('rejects missing type', () => {
const result = CreateTimerSchema.safeParse({
id: 'timer_001',
label: 'Morning alarm',
duration: 0,
targetTime: '2026-03-01T07:00:00.000Z',
});
expect(result.success).toBe(false);
});
it('rejects invalid type', () => {
const result = CreateTimerSchema.safeParse({ ...validMinimal, type: 'stopwatch' });
expect(result.success).toBe(false);
});
it('rejects invalid state', () => {
const result = CreateTimerSchema.safeParse({ ...validMinimal, state: 'deleted' });
expect(result.success).toBe(false);
});
it('rejects invalid urgency', () => {
const result = CreateTimerSchema.safeParse({ ...validMinimal, urgency: 'extreme' });
expect(result.success).toBe(false);
});
it('rejects invalid targetTime format', () => {
const result = CreateTimerSchema.safeParse({ ...validMinimal, targetTime: 'not-a-date' });
expect(result.success).toBe(false);
});
it('rejects negative duration', () => {
const result = CreateTimerSchema.safeParse({ ...validMinimal, duration: -10 });
expect(result.success).toBe(false);
});
it('rejects label > 500 chars', () => {
const result = CreateTimerSchema.safeParse({ ...validMinimal, label: 'x'.repeat(501) });
expect(result.success).toBe(false);
});
it('rejects pomodoro focusMinutes > 120', () => {
const result = CreateTimerSchema.safeParse({
...validMinimal,
pomodoro: {
focusMinutes: 150,
shortBreakMinutes: 5,
longBreakMinutes: 15,
roundsBeforeLong: 4,
currentRound: 0,
isBreak: false,
totalRoundsCompleted: 0,
},
});
expect(result.success).toBe(false);
});
});
// ── UpdateTimerSchema ──
describe('UpdateTimerSchema', () => {
it('accepts state update with syncVersion', () => {
const result = UpdateTimerSchema.safeParse({ state: 'paused', syncVersion: 2 });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.state).toBe('paused');
expect(result.data.syncVersion).toBe(2);
}
});
it('accepts complete update with timestamps', () => {
const result = UpdateTimerSchema.safeParse({
state: 'completed',
completedAt: '2026-03-01T08:00:00.000Z',
syncVersion: 5,
});
expect(result.success).toBe(true);
});
it('accepts pomodoro round update', () => {
const result = UpdateTimerSchema.safeParse({
pomodoro: {
focusMinutes: 25,
shortBreakMinutes: 5,
longBreakMinutes: 15,
roundsBeforeLong: 4,
currentRound: 3,
isBreak: true,
totalRoundsCompleted: 2,
},
syncVersion: 4,
});
expect(result.success).toBe(true);
});
it('requires syncVersion', () => {
const result = UpdateTimerSchema.safeParse({ state: 'paused' });
expect(result.success).toBe(false);
});
it('rejects syncVersion < 1', () => {
const result = UpdateTimerSchema.safeParse({ state: 'paused', syncVersion: 0 });
expect(result.success).toBe(false);
});
it('rejects invalid state', () => {
const result = UpdateTimerSchema.safeParse({ state: 'deleted', syncVersion: 2 });
expect(result.success).toBe(false);
});
it('rejects invalid completedAt format', () => {
const result = UpdateTimerSchema.safeParse({
completedAt: 'yesterday',
syncVersion: 2,
});
expect(result.success).toBe(false);
});
});
// ── TimerQuerySchema ──
describe('TimerQuerySchema', () => {
it('provides defaults for empty query', () => {
const result = TimerQuerySchema.safeParse({});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.sortBy).toBe('createdAt');
expect(result.data.sortOrder).toBe('desc');
expect(result.data.limit).toBe(50);
expect(result.data.offset).toBe(0);
}
});
it('coerces string numbers for limit and offset', () => {
const result = TimerQuerySchema.safeParse({ limit: '25', offset: '10' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.limit).toBe(25);
expect(result.data.offset).toBe(10);
}
});
it('accepts all filter combinations', () => {
const result = TimerQuerySchema.safeParse({
state: 'active',
type: 'pomodoro',
urgency: 'critical',
category: 'work',
sortBy: 'targetTime',
sortOrder: 'asc',
});
expect(result.success).toBe(true);
});
it('rejects limit > 100', () => {
const result = TimerQuerySchema.safeParse({ limit: 200 });
expect(result.success).toBe(false);
});
it('rejects negative offset', () => {
const result = TimerQuerySchema.safeParse({ offset: -1 });
expect(result.success).toBe(false);
});
it('rejects invalid sortBy', () => {
const result = TimerQuerySchema.safeParse({ sortBy: 'random' });
expect(result.success).toBe(false);
});
it('rejects invalid state filter', () => {
const result = TimerQuerySchema.safeParse({ state: 'deleted' });
expect(result.success).toBe(false);
});
});
// ── TimerSyncQuerySchema ──
describe('TimerSyncQuerySchema', () => {
it('accepts valid since timestamp', () => {
const result = TimerSyncQuerySchema.safeParse({ since: '2026-03-01T00:00:00.000Z' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.limit).toBe(100);
}
});
it('accepts custom limit', () => {
const result = TimerSyncQuerySchema.safeParse({
since: '2026-03-01T00:00:00.000Z',
limit: '50',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.limit).toBe(50);
}
});
it('rejects missing since', () => {
const result = TimerSyncQuerySchema.safeParse({});
expect(result.success).toBe(false);
});
it('rejects invalid since format', () => {
const result = TimerSyncQuerySchema.safeParse({ since: 'yesterday' });
expect(result.success).toBe(false);
});
it('rejects limit > 500', () => {
const result = TimerSyncQuerySchema.safeParse({
since: '2026-03-01T00:00:00.000Z',
limit: 1000,
});
expect(result.success).toBe(false);
});
});
// ── BatchUpsertSchema ──
describe('BatchUpsertSchema', () => {
const validTimer = {
id: 'timer_batch_1',
label: 'Batch timer',
type: 'countdown',
duration: 600,
targetTime: '2026-03-01T10:00:00.000Z',
};
it('accepts array of valid timers', () => {
const result = BatchUpsertSchema.safeParse({
timers: [validTimer, { ...validTimer, id: 'timer_batch_2', label: 'Second timer' }],
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.timers).toHaveLength(2);
}
});
it('rejects empty timers array', () => {
const result = BatchUpsertSchema.safeParse({ timers: [] });
expect(result.success).toBe(false);
});
it('rejects missing timers field', () => {
const result = BatchUpsertSchema.safeParse({});
expect(result.success).toBe(false);
});
it('validates each timer in the array', () => {
const result = BatchUpsertSchema.safeParse({
timers: [validTimer, { id: 'bad', type: 'invalid' }],
});
expect(result.success).toBe(false);
});
it('rejects > 100 timers', () => {
const timers = Array.from({ length: 101 }, (_, i) => ({
...validTimer,
id: `timer_${i}`,
}));
const result = BatchUpsertSchema.safeParse({ timers });
expect(result.success).toBe(false);
});
});

View File

@ -0,0 +1,175 @@
/**
* Timer types ChronoMind cross-platform cloud sync.
*
* Cosmos container: `timers` (partition key: `/userId`)
* Product ID: "chronomind"
*/
import { z } from 'zod';
// ── Enums / constants ──
export const TIMER_TYPES = ['countdown', 'alarm', 'pomodoro'] as const;
export type TimerType = (typeof TIMER_TYPES)[number];
export const TIMER_STATES = [
'active',
'paused',
'fired',
'snoozed',
'dismissed',
'completed',
'warning',
] as const;
export type TimerState = (typeof TIMER_STATES)[number];
export const URGENCY_LEVELS = ['critical', 'important', 'standard', 'gentle', 'passive'] as const;
export type UrgencyLevel = (typeof URGENCY_LEVELS)[number];
export const CASCADE_PRESETS = ['minimal', 'standard', 'aggressive', 'custom'] as const;
export type CascadePreset = (typeof CASCADE_PRESETS)[number];
// ── Sub-document interfaces ──
export interface CascadeConfig {
preset: CascadePreset;
intervals: number[];
}
export interface PomodoroConfig {
focusMinutes: number;
shortBreakMinutes: number;
longBreakMinutes: number;
roundsBeforeLong: number;
currentRound: number;
isBreak: boolean;
totalRoundsCompleted: number;
}
// ── Main document ──
export interface TimerDoc {
id: string;
userId: string;
productId: string;
label: string;
description?: string;
type: TimerType;
state: TimerState;
urgency: UrgencyLevel;
duration: number;
targetTime: string;
createdAt: string;
startedAt?: string;
pausedAt?: string;
firedAt?: string;
completedAt?: string;
cascade?: CascadeConfig;
pomodoro?: PomodoroConfig;
isCalendarSync?: boolean;
calendarEventId?: string;
category?: string;
deviceId?: string;
lastSyncedAt?: string;
syncVersion: number;
_ts?: number;
_etag?: string;
}
// ── Zod schemas ──
const CascadeSchema = z.object({
preset: z.enum(CASCADE_PRESETS),
intervals: z.array(z.number().int().min(0)),
});
const PomodoroSchema = z.object({
focusMinutes: z.number().int().min(1).max(120),
shortBreakMinutes: z.number().int().min(1).max(60),
longBreakMinutes: z.number().int().min(1).max(120),
roundsBeforeLong: z.number().int().min(1).max(20),
currentRound: z.number().int().min(0),
isBreak: z.boolean(),
totalRoundsCompleted: z.number().int().min(0),
});
export const CreateTimerSchema = z.object({
id: z.string().min(1).max(128),
label: z.string().min(1).max(500),
description: z.string().max(2000).optional(),
type: z.enum(TIMER_TYPES),
state: z.enum(TIMER_STATES).default('active'),
urgency: z.enum(URGENCY_LEVELS).default('standard'),
duration: z.number().int().min(0),
targetTime: z.string().datetime(),
cascade: CascadeSchema.optional(),
pomodoro: PomodoroSchema.optional(),
isCalendarSync: z.boolean().optional(),
calendarEventId: z.string().max(500).optional(),
category: z.string().max(128).optional(),
deviceId: z.string().max(256).optional(),
startedAt: z.string().datetime().optional(),
syncVersion: z.number().int().min(0).default(1),
});
export const UpdateTimerSchema = z.object({
label: z.string().min(1).max(500).optional(),
description: z.string().max(2000).optional(),
state: z.enum(TIMER_STATES).optional(),
urgency: z.enum(URGENCY_LEVELS).optional(),
duration: z.number().int().min(0).optional(),
targetTime: z.string().datetime().optional(),
startedAt: z.string().datetime().optional(),
pausedAt: z.string().datetime().optional(),
firedAt: z.string().datetime().optional(),
completedAt: z.string().datetime().optional(),
cascade: CascadeSchema.optional(),
pomodoro: PomodoroSchema.optional(),
isCalendarSync: z.boolean().optional(),
calendarEventId: z.string().max(500).optional(),
category: z.string().max(128).optional(),
deviceId: z.string().max(256).optional(),
syncVersion: z.number().int().min(1),
});
export const TimerQuerySchema = z.object({
state: z.enum(TIMER_STATES).optional(),
type: z.enum(TIMER_TYPES).optional(),
urgency: z.enum(URGENCY_LEVELS).optional(),
category: z.string().optional(),
sortBy: z.enum(['createdAt', 'targetTime', 'label']).default('createdAt'),
sortOrder: z.enum(['asc', 'desc']).default('desc'),
limit: z.coerce.number().int().min(1).max(100).default(50),
offset: z.coerce.number().int().min(0).default(0),
});
export const TimerSyncQuerySchema = z.object({
since: z.string().datetime(),
limit: z.coerce.number().int().min(1).max(500).default(100),
});
export const BatchUpsertSchema = z.object({
timers: z.array(CreateTimerSchema).min(1).max(100),
});
// ── Inferred types ──
export type CreateTimerInput = z.infer<typeof CreateTimerSchema>;
export type UpdateTimerInput = z.infer<typeof UpdateTimerSchema>;
export type TimerQuery = z.infer<typeof TimerQuerySchema>;
export type TimerSyncQuery = z.infer<typeof TimerSyncQuerySchema>;
export type BatchUpsertInput = z.infer<typeof BatchUpsertSchema>;
// ── Batch result ──
export interface BatchUpsertResult {
synced: string[];
conflicts: Array<{ id: string; serverVersion: number }>;
errors: Array<{ id: string; error: string }>;
}

View File

@ -50,6 +50,7 @@ import { telemetryRoutes } from './modules/telemetry/routes.js';
import { fastingSessionRoutes } from './modules/fasting-sessions/routes.js';
import { fastingProtocolRoutes } from './modules/fasting-protocols/routes.js';
import { bodyStageRoutes } from './modules/body-stages/routes.js';
import { timerRoutes } from './modules/timers/routes.js';
import { initCosmosIfNeeded } from './lib/cosmos-init.js';
import { config } from './lib/config.js';
@ -128,5 +129,7 @@ await app.register(publicRoutes, { prefix: '/api' });
await app.register(fastingSessionRoutes, { prefix: '/api' });
await app.register(fastingProtocolRoutes, { prefix: '/api' });
await app.register(bodyStageRoutes, { prefix: '/api' });
// ChronoMind timer module
await app.register(timerRoutes, { prefix: '/api' });
await startService(app, { port: config.PORT, host: config.HOST });