diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index b721f1a..3c98363 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -8,6 +8,7 @@ import { JobsModule } from "./modules/jobs/jobs.module"; import { TodosModule } from "./modules/todos/todos.module"; import { AiModule } from "./modules/ai/ai.module"; import { HealthTabModule } from "./modules/health-tab/health-tab.module"; +import { AgendaModule } from "./modules/agenda/agenda.module"; @Module({ imports: [ @@ -19,6 +20,7 @@ import { HealthTabModule } from "./modules/health-tab/health-tab.module"; TodosModule, AiModule, HealthTabModule, + AgendaModule, ], }) export class AppModule implements NestModule { @@ -27,9 +29,8 @@ export class AppModule implements NestModule { .apply(BearerMiddleware) .exclude( { path: "health", method: RequestMethod.GET }, - // Endpoints publics (signés autrement) ajoutés en Phase 4/6 : - // { path: "telegram/webhook", method: RequestMethod.POST }, - // { path: "agenda/ical/:secret.ics", method: RequestMethod.GET }, + { path: "agenda/ical/:secret", method: RequestMethod.GET }, + { path: "agenda/google/oauth/callback", method: RequestMethod.GET }, ) .forRoutes("*"); } diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index fdd35bb..b8275df 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -27,6 +27,8 @@ const EnvSchema = z.object({ GROQ_API_KEY: z.string().optional(), GROQ_STT_MODEL: z.string().default("whisper-large-v3-turbo"), + PWA_URL: z.string().url().default("http://localhost:5173"), + GOOGLE_OAUTH_CLIENT_ID: z.string().optional(), GOOGLE_OAUTH_CLIENT_SECRET: z.string().optional(), GOOGLE_OAUTH_REDIRECT_URI: z.string().url().optional(), diff --git a/apps/api/src/modules/agenda/agenda.controller.ts b/apps/api/src/modules/agenda/agenda.controller.ts new file mode 100644 index 0000000..668ea8e --- /dev/null +++ b/apps/api/src/modules/agenda/agenda.controller.ts @@ -0,0 +1,100 @@ +import { + Body, + Controller, + Delete, + Get, + Inject, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, + Res, +} from "@nestjs/common"; +import type { Response } from "express"; +import { + CalendarEventCreateDto, + CalendarEventListQuery, + CalendarEventPatchDto, +} from "@ordinarthur-os/shared"; +import { APP_CONFIG } from "@/config/config.module"; +import type { AppConfig } from "@/config/env"; +import { ZodPipe } from "@/lib/zod-pipe"; +import { AgendaService } from "./agenda.service"; + +@Controller("agenda") +export class AgendaController { + constructor( + private readonly agenda: AgendaService, + @Inject(APP_CONFIG) private readonly config: AppConfig, + ) {} + + // ── CRUD événements ──────────────────────────────────────────────────────── + + @Get("events") + list(@Query(new ZodPipe(CalendarEventListQuery)) query: CalendarEventListQuery) { + return this.agenda.list(query); + } + + @Post("events") + create(@Body(new ZodPipe(CalendarEventCreateDto)) dto: CalendarEventCreateDto) { + return this.agenda.create(dto); + } + + @Patch("events/:id") + patch( + @Param("id", new ParseUUIDPipe()) id: string, + @Body(new ZodPipe(CalendarEventPatchDto)) dto: CalendarEventPatchDto, + ) { + return this.agenda.patch(id, dto); + } + + @Delete("events/:id") + remove(@Param("id", new ParseUUIDPipe()) id: string) { + return this.agenda.remove(id); + } + + // ── Google OAuth ─────────────────────────────────────────────────────────── + + @Get("google/status") + googleStatus() { + return this.agenda.getGoogleStatus(); + } + + // Retourne { url } au lieu de rediriger : le frontend appelle avec le bearer + // token puis fait window.location.href = url côté navigateur. + @Get("google/oauth/start") + oauthStart(): { url: string } { + return { url: this.agenda.getOAuthUrl() }; + } + + @Get("google/oauth/callback") + async oauthCallback(@Query("code") code: string, @Res() res: Response) { + await this.agenda.handleOAuthCallback(code); + const pwaUrl = this.agenda.getPwaUrl(); + res.redirect(`${pwaUrl}/agenda`); + } + + @Post("google/sync") + sync() { + return this.agenda.syncGoogle(); + } + + // ── iCal feed (endpoint public — secret dans l'URL) ─────────────────────── + + @Get("ical/:secret") + async ical( + @Param("secret") secret: string, + @Res() res: Response, + ) { + const expected = this.config.ICAL_FEED_SECRET; + if (!expected || secret !== expected) { + res.status(403).send("Forbidden"); + return; + } + const ical = await this.agenda.buildIcal(); + res.setHeader("Content-Type", "text/calendar; charset=utf-8"); + res.setHeader("Content-Disposition", 'attachment; filename="ordinarthur-os.ics"'); + res.send(ical); + } +} diff --git a/apps/api/src/modules/agenda/agenda.module.ts b/apps/api/src/modules/agenda/agenda.module.ts new file mode 100644 index 0000000..b0ff36c --- /dev/null +++ b/apps/api/src/modules/agenda/agenda.module.ts @@ -0,0 +1,9 @@ +import { Module } from "@nestjs/common"; +import { AgendaController } from "./agenda.controller"; +import { AgendaService } from "./agenda.service"; + +@Module({ + controllers: [AgendaController], + providers: [AgendaService], +}) +export class AgendaModule {} diff --git a/apps/api/src/modules/agenda/agenda.service.ts b/apps/api/src/modules/agenda/agenda.service.ts new file mode 100644 index 0000000..9e717bd --- /dev/null +++ b/apps/api/src/modules/agenda/agenda.service.ts @@ -0,0 +1,373 @@ +import { Inject, Injectable, NotFoundException } from "@nestjs/common"; +import { and, eq, gte, lte, sql } from "drizzle-orm"; +import { schema } from "@ordinarthur-os/db"; +import type { + CalendarEvent, + CalendarEventCreateDto, + CalendarEventListQuery, + CalendarEventPatchDto, + GoogleSyncResponse, +} from "@ordinarthur-os/shared"; +import { InjectDb, type Db } from "@/db/db.module"; +import { APP_CONFIG } from "@/config/config.module"; +import type { AppConfig } from "@/config/env"; + +const { calendarEvents, googleOAuthTokens } = schema; + +// --------------------------------------------------------------------------- +// Types Google Calendar REST API (subset) +// --------------------------------------------------------------------------- + +interface GoogleTokenResponse { + access_token: string; + refresh_token?: string; + expires_in: number; + token_type: string; +} + +interface GoogleCalendarEvent { + id: string; + summary?: string; + description?: string; + location?: string; + start: { dateTime?: string; date?: string }; + end: { dateTime?: string; date?: string }; + status?: string; +} + +@Injectable() +export class AgendaService { + constructor( + @InjectDb() private readonly db: Db, + @Inject(APP_CONFIG) private readonly config: AppConfig, + ) {} + + // --------------------------------------------------------------------------- + // CRUD + // --------------------------------------------------------------------------- + + async list(query: CalendarEventListQuery): Promise { + const conditions = []; + if (query.from) conditions.push(gte(calendarEvents.startsAt, new Date(query.from))); + if (query.to) conditions.push(lte(calendarEvents.endsAt, new Date(query.to))); + + const rows = await this.db + .select() + .from(calendarEvents) + .where(conditions.length ? and(...conditions) : undefined) + .orderBy(calendarEvents.startsAt); + + return rows.map(rowToDto); + } + + async create(dto: CalendarEventCreateDto): Promise { + const [row] = await this.db + .insert(calendarEvents) + .values({ + title: dto.title, + description: dto.description ?? null, + location: dto.location ?? null, + startsAt: new Date(dto.starts_at), + endsAt: new Date(dto.ends_at), + allDay: dto.all_day ?? false, + source: "ordinarthur-os", + }) + .returning(); + if (!row) throw new Error("Insert returned no row"); + return rowToDto(row); + } + + async patch(id: string, dto: CalendarEventPatchDto): Promise { + const set: Partial = { + updatedAt: new Date(), + }; + if (dto.title !== undefined) set.title = dto.title; + if (dto.description !== undefined) set.description = dto.description ?? null; + if (dto.location !== undefined) set.location = dto.location ?? null; + if (dto.starts_at !== undefined) set.startsAt = new Date(dto.starts_at); + if (dto.ends_at !== undefined) set.endsAt = new Date(dto.ends_at); + if (dto.all_day !== undefined) set.allDay = dto.all_day; + + const [row] = await this.db + .update(calendarEvents) + .set(set) + .where(eq(calendarEvents.id, id)) + .returning(); + if (!row) throw new NotFoundException(`CalendarEvent ${id} not found`); + return rowToDto(row); + } + + async remove(id: string): Promise { + await this.db.delete(calendarEvents).where(eq(calendarEvents.id, id)); + } + + // --------------------------------------------------------------------------- + // Google OAuth + // --------------------------------------------------------------------------- + + async getGoogleStatus(): Promise<{ connected: boolean }> { + const [token] = await this.db.select().from(googleOAuthTokens); + return { connected: !!token?.refreshToken }; + } + + getPwaUrl(): string { + return this.config.PWA_URL; + } + + getOAuthUrl(): string { + const { GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_REDIRECT_URI } = this.config; + if (!GOOGLE_OAUTH_CLIENT_ID || !GOOGLE_OAUTH_REDIRECT_URI) { + throw new Error("Google OAuth not configured"); + } + const params = new URLSearchParams({ + client_id: GOOGLE_OAUTH_CLIENT_ID, + redirect_uri: GOOGLE_OAUTH_REDIRECT_URI, + response_type: "code", + scope: "https://www.googleapis.com/auth/calendar.readonly", + access_type: "offline", + prompt: "consent", + }); + return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`; + } + + async handleOAuthCallback(code: string): Promise { + const { GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRET, GOOGLE_OAUTH_REDIRECT_URI } = + this.config; + if (!GOOGLE_OAUTH_CLIENT_ID || !GOOGLE_OAUTH_CLIENT_SECRET || !GOOGLE_OAUTH_REDIRECT_URI) { + throw new Error("Google OAuth not configured"); + } + + const res = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + code, + client_id: GOOGLE_OAUTH_CLIENT_ID, + client_secret: GOOGLE_OAUTH_CLIENT_SECRET, + redirect_uri: GOOGLE_OAUTH_REDIRECT_URI, + grant_type: "authorization_code", + }), + }); + + if (!res.ok) throw new Error(`Google token exchange failed: ${res.statusText}`); + const tokens = (await res.json()) as GoogleTokenResponse; + + const expiresAt = new Date(Date.now() + tokens.expires_in * 1000); + await this.db + .insert(googleOAuthTokens) + .values({ + id: 1, + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token ?? "", + expiresAt, + }) + .onConflictDoUpdate({ + target: googleOAuthTokens.id, + set: { + accessToken: tokens.access_token, + ...(tokens.refresh_token ? { refreshToken: tokens.refresh_token } : {}), + expiresAt, + }, + }); + } + + // --------------------------------------------------------------------------- + // Sync Google Calendar → local DB + // --------------------------------------------------------------------------- + + async syncGoogle(): Promise { + const accessToken = await this.getValidAccessToken(); + if (!accessToken) throw new Error("Google OAuth not connected"); + + const [tokenRow] = await this.db.select().from(googleOAuthTokens); + const calendarId = tokenRow?.calendarId ?? "primary"; + + const from = new Date(); + from.setDate(from.getDate() - 7); + const to = new Date(); + to.setDate(to.getDate() + 60); + + const params = new URLSearchParams({ + timeMin: from.toISOString(), + timeMax: to.toISOString(), + singleEvents: "true", + orderBy: "startTime", + maxResults: "250", + }); + + const res = await fetch( + `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events?${params}`, + { headers: { Authorization: `Bearer ${accessToken}` } }, + ); + if (!res.ok) throw new Error(`Google Calendar API error: ${res.statusText}`); + + const data = (await res.json()) as { items: GoogleCalendarEvent[] }; + let inserted = 0; + let updated = 0; + + for (const ev of data.items ?? []) { + if (ev.status === "cancelled") continue; + const allDay = !ev.start.dateTime; + const startsAt = new Date(ev.start.dateTime ?? ev.start.date ?? ""); + const endsAt = new Date(ev.end.dateTime ?? ev.end.date ?? ""); + if (isNaN(startsAt.getTime()) || isNaN(endsAt.getTime())) continue; + + const existing = await this.db + .select({ id: calendarEvents.id }) + .from(calendarEvents) + .where(eq(calendarEvents.googleEventId, ev.id)); + + if (existing.length) { + await this.db + .update(calendarEvents) + .set({ + title: ev.summary ?? "(sans titre)", + description: ev.description ?? null, + location: ev.location ?? null, + startsAt, + endsAt, + allDay, + updatedAt: new Date(), + }) + .where(eq(calendarEvents.googleEventId, ev.id)); + updated++; + } else { + await this.db.insert(calendarEvents).values({ + googleEventId: ev.id, + title: ev.summary ?? "(sans titre)", + description: ev.description ?? null, + location: ev.location ?? null, + startsAt, + endsAt, + allDay, + source: "google", + }); + inserted++; + } + } + + return { inserted, updated }; + } + + // --------------------------------------------------------------------------- + // iCal feed + // --------------------------------------------------------------------------- + + async buildIcal(): Promise { + const from = new Date(); + from.setDate(from.getDate() - 30); + const to = new Date(); + to.setDate(to.getDate() + 180); + + const rows = await this.db + .select() + .from(calendarEvents) + .where(and(gte(calendarEvents.startsAt, from), lte(calendarEvents.endsAt, to))) + .orderBy(calendarEvents.startsAt); + + const vevents = rows.map((row) => { + const uid = `${row.id}@ordinarthur-os`; + const dtstart = row.allDay + ? formatIcalDate(row.startsAt) + : `${formatIcalDateTime(row.startsAt)}`; + const dtend = row.allDay + ? formatIcalDate(row.endsAt) + : `${formatIcalDateTime(row.endsAt)}`; + const dtstartProp = row.allDay ? `DTSTART;VALUE=DATE:${dtstart}` : `DTSTART:${dtstart}`; + const dtendProp = row.allDay ? `DTEND;VALUE=DATE:${dtend}` : `DTEND:${dtend}`; + const lines = [ + "BEGIN:VEVENT", + `UID:${uid}`, + dtstartProp, + dtendProp, + `SUMMARY:${escapeIcal(row.title)}`, + row.description ? `DESCRIPTION:${escapeIcal(row.description)}` : null, + row.location ? `LOCATION:${escapeIcal(row.location)}` : null, + `DTSTAMP:${formatIcalDateTime(row.updatedAt)}`, + "END:VEVENT", + ]; + return lines.filter(Boolean).join("\r\n"); + }); + + return [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//ordinarthur-os//ordinarthur-os//FR", + "CALSCALE:GREGORIAN", + "METHOD:PUBLISH", + "X-WR-CALNAME:ordinarthur-os", + ...vevents, + "END:VCALENDAR", + ].join("\r\n"); + } + + // --------------------------------------------------------------------------- + // Helpers privés + // --------------------------------------------------------------------------- + + private async getValidAccessToken(): Promise { + const [token] = await this.db.select().from(googleOAuthTokens); + if (!token) return null; + + if (token.expiresAt > new Date(Date.now() + 60_000)) { + return token.accessToken; + } + + const { GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRET } = this.config; + if (!GOOGLE_OAUTH_CLIENT_ID || !GOOGLE_OAUTH_CLIENT_SECRET) return null; + + const res = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: GOOGLE_OAUTH_CLIENT_ID, + client_secret: GOOGLE_OAUTH_CLIENT_SECRET, + refresh_token: token.refreshToken, + grant_type: "refresh_token", + }), + }); + + if (!res.ok) return null; + const refreshed = (await res.json()) as GoogleTokenResponse; + const expiresAt = new Date(Date.now() + refreshed.expires_in * 1000); + + await this.db + .update(googleOAuthTokens) + .set({ accessToken: refreshed.access_token, expiresAt }) + .where(eq(googleOAuthTokens.id, 1)); + + return refreshed.access_token; + } +} + +// --------------------------------------------------------------------------- +// Helpers purs +// --------------------------------------------------------------------------- + +function rowToDto(row: typeof calendarEvents.$inferSelect): CalendarEvent { + return { + id: row.id, + google_event_id: row.googleEventId ?? null, + title: row.title, + description: row.description ?? null, + location: row.location ?? null, + starts_at: row.startsAt.toISOString(), + ends_at: row.endsAt.toISOString(), + all_day: row.allDay, + source: row.source, + created_at: row.createdAt.toISOString(), + updated_at: row.updatedAt.toISOString(), + }; +} + +function formatIcalDateTime(d: Date): string { + return d.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, ""); +} + +function formatIcalDate(d: Date): string { + return d.toISOString().slice(0, 10).replace(/-/g, ""); +} + +function escapeIcal(s: string): string { + return s.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\n/g, "\\n"); +} diff --git a/apps/api/src/modules/ai/ai.controller.ts b/apps/api/src/modules/ai/ai.controller.ts index 119d5b1..4cbd794 100644 --- a/apps/api/src/modules/ai/ai.controller.ts +++ b/apps/api/src/modules/ai/ai.controller.ts @@ -2,6 +2,10 @@ import { BadRequestException, Body, Controller, + HttpCode, + Param, + ParseUUIDPipe, + Patch, Post, Req, } from "@nestjs/common"; @@ -42,6 +46,15 @@ export class AiController { return this.ai.voice(body); } + @Patch("actions/:id/args") + @HttpCode(204) + patchArgs( + @Param("id", new ParseUUIDPipe()) id: string, + @Body() body: { args: Record }, + ): Promise { + return this.ai.patchArgs(id, body.args); + } + @Post("command/confirm") confirm( @Body(new ZodPipe(AiConfirmRequest)) body: AiConfirmRequest, diff --git a/apps/api/src/modules/ai/ai.service.ts b/apps/api/src/modules/ai/ai.service.ts index 99fd716..6824577 100644 --- a/apps/api/src/modules/ai/ai.service.ts +++ b/apps/api/src/modules/ai/ai.service.ts @@ -9,12 +9,12 @@ import { ProposedActionWithId, TodoCreateDto, } from "@ordinarthur-os/shared"; -import { eq, inArray } from "drizzle-orm"; +import { eq, inArray, sql } from "drizzle-orm"; import { InjectDb, type Db } from "@/db/db.module"; import { MistralClient } from "./mistral.client"; import { GroqClient } from "./groq.client"; -const { aiActions, todos } = schema; +const { aiActions, todos, calendarEvents, dailyCheckins } = schema; @Injectable() export class AiService { @@ -46,6 +46,17 @@ export class AiService { return { transcript, actions }; } + async patchArgs(id: string, args: Record): Promise { + const [row] = await this.db.select().from(aiActions).where(eq(aiActions.id, id)); + if (!row || row.status !== "proposed") { + throw new NotFoundException(`Action ${id} introuvable ou déjà traitée`); + } + await this.db + .update(aiActions) + .set({ functionArgs: args }) + .where(eq(aiActions.id, id)); + } + async confirm(actionIds: string[]): Promise { const rows = await this.db .select() @@ -178,9 +189,42 @@ export class AiService { case "add_project_idea": case "add_project_step": - case "create_calendar_event": - case "toggle_daily_checkin": - throw new Error(`Fonction "${action.fn}" pas encore câblée (phase ultérieure)`); + throw new Error(`Fonction "${action.fn}" pas encore câblée (phase 3)`); + + case "create_calendar_event": { + const [row] = await this.db + .insert(calendarEvents) + .values({ + title: action.args.title, + description: action.args.description ?? null, + location: action.args.location ?? null, + startsAt: new Date(action.args.starts_at), + endsAt: new Date(action.args.ends_at), + allDay: false, + source: "ordinarthur-os", + }) + .returning(); + if (!row) throw new Error("Insertion calendar_event: aucune ligne renvoyée"); + return { event_id: row.id }; + } + + case "toggle_daily_checkin": { + const today = new Date().toLocaleDateString("fr-CA", { timeZone: "Europe/Paris" }); + const [row] = await this.db + .insert(dailyCheckins) + .values({ day: today, medsTaken: true, note: action.args.note ?? null }) + .onConflictDoUpdate({ + target: dailyCheckins.day, + set: { + medsTaken: sql`NOT ${dailyCheckins.medsTaken}`, + note: action.args.note ?? null, + updatedAt: sql`now()`, + }, + }) + .returning(); + if (!row) throw new Error("Upsert daily_checkin: aucune ligne renvoyée"); + return { day: row.day, meds_taken: row.medsTaken }; + } } } } diff --git a/apps/api/src/modules/ai/mistral.client.ts b/apps/api/src/modules/ai/mistral.client.ts index 967fb62..56af1df 100644 --- a/apps/api/src/modules/ai/mistral.client.ts +++ b/apps/api/src/modules/ai/mistral.client.ts @@ -122,6 +122,12 @@ interface MistralResponse { }>; } +function isDST(): boolean { + const jan = new Date(new Date().getFullYear(), 0, 1).getTimezoneOffset(); + const jul = new Date(new Date().getFullYear(), 6, 1).getTimezoneOffset(); + return new Date().getTimezoneOffset() < Math.max(jan, jul); +} + const SYSTEM_PROMPT = `Tu es l'assistant personnel d'Arthur dans ordinarthur-os. - Réponds UNIQUEMENT en français. @@ -129,7 +135,9 @@ const SYSTEM_PROMPT = `Tu es l'assistant personnel d'Arthur dans ordinarthur-os. - Tu ne poses pas de questions : si une info manque, tu proposes un titre court et laisses les champs optionnels vides. - Si la demande ne matche aucune fonction (ex : question / discussion), n'appelle aucun outil. - N'exécute rien toi-même : tu ne fais que proposer. L'utilisateur confirmera côté UI. -- Dates : convertis les expressions relatives ('demain', 'vendredi soir', 'dans 2h') en ISO 8601 UTC en te basant sur l'heure courante fournie.`; +- Traite les formulations déclaratives comme des demandes d'action. "j'ai rendez-vous", "je dois aller", "j'ai un rdv", "dans mon agenda" → create_calendar_event. "j'ai pris mes médocs", "médicaments pris" → toggle_daily_checkin. +- Pour create_calendar_event : si la durée n'est pas précisée, suppose 1h. Si seule l'heure de début est donnée, calcule ends_at = starts_at + 1h. +- Dates : convertis les expressions relatives ('demain', 'vendredi soir', 'dans 2h') en ISO 8601 avec l'offset Europe/Paris (+02:00 en été, +01:00 en hiver) en te basant sur l'heure courante fournie. Ex : "2024-04-16T17:40:00+02:00". Ne jamais utiliser UTC si l'utilisateur parle d'une heure locale.`; @Injectable() export class MistralClient { @@ -142,7 +150,11 @@ export class MistralClient { throw new ServiceUnavailableException("MISTRAL_API_KEY non configurée"); } - const now = new Date().toISOString(); + const now = new Date().toLocaleString("sv-SE", { + timeZone: "Europe/Paris", + year: "numeric", month: "2-digit", day: "2-digit", + hour: "2-digit", minute: "2-digit", second: "2-digit", + }).replace(" ", "T") + (isDST() ? "+02:00" : "+01:00"); const res = await fetch("https://api.mistral.ai/v1/chat/completions", { method: "POST", headers: { diff --git a/apps/pwa/src/components/agenda/EventCard.tsx b/apps/pwa/src/components/agenda/EventCard.tsx new file mode 100644 index 0000000..b470851 --- /dev/null +++ b/apps/pwa/src/components/agenda/EventCard.tsx @@ -0,0 +1,58 @@ +import type { CalendarEvent } from "@ordinarthur-os/shared"; + +interface Props { + event: CalendarEvent; + onDelete?: (id: string) => void; +} + +export function EventCard({ event, onDelete }: Props) { + const timeLabel = event.all_day + ? "Toute la journée" + : `${formatTime(event.starts_at)} – ${formatTime(event.ends_at)}`; + + return ( +
+ {/* Barre couleur + heure */} +
+ + {timeLabel} + + {event.source === "google" && ( + + Google + + )} +
+ + {/* Séparateur vertical */} +
+ + {/* Contenu */} +
+

{event.title}

+ {event.location && ( +

{event.location}

+ )} +
+ + {/* Supprimer (événements locaux uniquement) */} + {onDelete && event.source !== "google" && ( + + )} +
+ ); +} + +function formatTime(iso: string): string { + return new Date(iso).toLocaleTimeString("fr-FR", { + hour: "2-digit", + minute: "2-digit", + }); +} diff --git a/apps/pwa/src/components/agenda/NewEventForm.tsx b/apps/pwa/src/components/agenda/NewEventForm.tsx new file mode 100644 index 0000000..0ef268b --- /dev/null +++ b/apps/pwa/src/components/agenda/NewEventForm.tsx @@ -0,0 +1,122 @@ +import { useState } from "react"; +import type { CalendarEventCreateDto } from "@ordinarthur-os/shared"; + +interface Props { + defaultDate: Date; + onSubmit: (dto: CalendarEventCreateDto) => void; + onCancel: () => void; + isPending?: boolean; +} + +export function NewEventForm({ defaultDate, onSubmit, onCancel, isPending = false }: Props) { + const dateStr = toLocalDatetimeValue(defaultDate); + + const [title, setTitle] = useState(""); + const [startsAt, setStartsAt] = useState(dateStr); + const [endsAt, setEndsAt] = useState(addHour(dateStr)); + const [location, setLocation] = useState(""); + const [allDay, setAllDay] = useState(false); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!title.trim()) return; + onSubmit({ + title: title.trim(), + starts_at: new Date(startsAt).toISOString(), + ends_at: new Date(endsAt).toISOString(), + location: location.trim() || null, + all_day: allDay, + }); + } + + return ( +
+ {/* Titre */} + setTitle(e.target.value)} + className="w-full bg-transparent px-4 py-3 font-sans text-sm text-ink outline-none placeholder:text-muted" + /> + + {/* Tout la journée */} + + + {/* Dates */} + {!allDay && ( +
+
+ Début + setStartsAt(e.target.value)} + className="w-full bg-transparent font-sans text-sm text-ink outline-none" + /> +
+
+ Fin + setEndsAt(e.target.value)} + className="w-full bg-transparent font-sans text-sm text-ink outline-none" + /> +
+
+ )} + + {/* Lieu */} + setLocation(e.target.value)} + className="w-full bg-transparent px-4 py-3 font-sans text-sm text-ink outline-none placeholder:text-muted" + /> + + {/* Actions */} +
+ + +
+
+ ); +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function toLocalDatetimeValue(d: Date): string { + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +function addHour(s: string): string { + const d = new Date(s); + d.setHours(d.getHours() + 1); + return toLocalDatetimeValue(d); +} diff --git a/apps/pwa/src/components/agenda/WeekStrip.tsx b/apps/pwa/src/components/agenda/WeekStrip.tsx new file mode 100644 index 0000000..4f23ec5 --- /dev/null +++ b/apps/pwa/src/components/agenda/WeekStrip.tsx @@ -0,0 +1,96 @@ +const DAYS_SHORT = ["Dim", "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam"]; + +interface Props { + selectedDate: Date; + onSelect: (date: Date) => void; + onPrevWeek: () => void; + onNextWeek: () => void; +} + +export function WeekStrip({ selectedDate, onSelect, onPrevWeek, onNextWeek }: Props) { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const weekDays = getWeekDays(selectedDate); + const [first, last] = [weekDays[0]!, weekDays[6]!]; + + return ( +
+ {/* Navigation semaine */} +
+ + + {formatWeekLabel(first, last)} + + +
+ + {/* Jours */} +
+ {weekDays.map((day) => { + const isToday = day.getTime() === today.getTime(); + const isSelected = day.getTime() === normalize(selectedDate).getTime(); + return ( + + ); + })} +
+
+ ); +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function normalize(d: Date): Date { + const c = new Date(d); + c.setHours(0, 0, 0, 0); + return c; +} + +function getWeekDays(ref: Date): Date[] { + const d = normalize(ref); + // Lundi = premier jour de la semaine + const day = d.getDay() === 0 ? 6 : d.getDay() - 1; + const monday = new Date(d); + monday.setDate(d.getDate() - day); + return Array.from({ length: 7 }, (_, i) => { + const date = new Date(monday); + date.setDate(monday.getDate() + i); + return date; + }); +} + +function formatWeekLabel(from: Date, to: Date): string { + const f = (d: Date) => + d.toLocaleDateString("fr-FR", { day: "numeric", month: "short" }); + return `${f(from)} – ${f(to)}`; +} diff --git a/apps/pwa/src/components/ai/ActionEditor.tsx b/apps/pwa/src/components/ai/ActionEditor.tsx new file mode 100644 index 0000000..b086496 --- /dev/null +++ b/apps/pwa/src/components/ai/ActionEditor.tsx @@ -0,0 +1,167 @@ +import type { ProposedAction } from "@ordinarthur-os/shared"; + +interface Props { + action: ProposedAction; + onChange: (updated: ProposedAction) => void; +} + +/** + * Formulaire d'édition inline adapté au type d'action. + * Chaque type d'action a son propre UI. + */ +export function ActionEditor({ action, onChange }: Props) { + switch (action.fn) { + case "create_calendar_event": + return ; + case "create_todo": + return ; + default: + return null; + } +} + +// ── Éditeur d'événement calendrier ─────────────────────────────────────────── + +function CalendarEventEditor({ + action, + onChange, +}: { + action: Extract; + onChange: (a: ProposedAction) => void; +}) { + const args = action.args; + + function patch(partial: Partial) { + onChange({ ...action, args: { ...args, ...partial } }); + } + + return ( +
+ {/* Titre */} +
+ Titre + patch({ title: e.target.value })} + className="w-full bg-transparent font-sans text-sm text-ink outline-none border-b border-ink focus:border-accent" + /> +
+ + {/* Début / Fin */} +
+
+ Début + patch({ starts_at: fromLocalInput(e.target.value) })} + className="w-full bg-transparent font-sans text-sm text-ink outline-none" + /> +
+
+ Fin + patch({ ends_at: fromLocalInput(e.target.value) })} + className="w-full bg-transparent font-sans text-sm text-ink outline-none" + /> +
+
+ + {/* Lieu */} +
+ Lieu (optionnel) + patch({ location: e.target.value || undefined })} + className="w-full bg-transparent font-sans text-sm text-ink outline-none border-b border-ink focus:border-accent" + /> +
+
+ ); +} + +// ── Éditeur de tâche ───────────────────────────────────────────────────────── + +function TodoEditor({ + action, + onChange, +}: { + action: Extract; + onChange: (a: ProposedAction) => void; +}) { + const args = action.args; + + function patch(partial: Partial) { + onChange({ ...action, args: { ...args, ...partial } }); + } + + return ( +
+ {/* Titre */} +
+ Titre + patch({ title: e.target.value })} + className="w-full bg-transparent font-sans text-sm text-ink outline-none border-b border-ink focus:border-accent" + /> +
+ + {/* Échéance */} +
+ Échéance (optionnel) + + patch({ due_at: e.target.value ? fromLocalInput(e.target.value) : undefined }) + } + className="w-full bg-transparent font-sans text-sm text-ink outline-none" + /> +
+ + {/* Priorité */} +
+ Priorité + {([0, 1, 2, 3] as const).map((p) => ( + + ))} +
+
+ ); +} + +// ── Helpers datetime-local ──────────────────────────────────────────────────── + +/** ISO 8601 (avec ou sans offset) → valeur pour input[type=datetime-local] */ +function toLocalInput(iso: string): string { + if (!iso) return ""; + const d = new Date(iso); + if (isNaN(d.getTime())) return ""; + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +/** Valeur datetime-local → ISO 8601 avec offset Europe/Paris */ +function fromLocalInput(value: string): string { + if (!value) return ""; + const d = new Date(value); // interprété en heure locale du navigateur + return d.toISOString(); // UTC en base, correct pour le stockage +} diff --git a/apps/pwa/src/components/ai/VoiceConfirmModal.tsx b/apps/pwa/src/components/ai/VoiceConfirmModal.tsx index d8b4c18..4d90df3 100644 --- a/apps/pwa/src/components/ai/VoiceConfirmModal.tsx +++ b/apps/pwa/src/components/ai/VoiceConfirmModal.tsx @@ -10,25 +10,29 @@ import type { import { api } from "@/api/client"; import { Label } from "@/design"; import { cn } from "@/lib/cn"; +import { ActionEditor } from "./ActionEditor"; interface Props { response: AiVoiceResponse; onClose: () => void; } -/** - * Modale de confirmation : l'IA a proposé N actions, l'utilisateur les - * confirme ou annule en bloc (ou décoche certaines). Tant que le bouton - * "Confirmer" n'a pas été pressé, RIEN n'est écrit en base. - */ export function VoiceConfirmModal({ response, onClose }: Props) { const queryClient = useQueryClient(); + + // Actions sélectionnées (toutes par défaut) const initialIds = useMemo( () => new Set(response.actions.map((a) => a.id)), [response.actions], ); const [selected, setSelected] = useState>(initialIds); + // Args édités localement avant confirmation + const [editedArgs, setEditedArgs] = useState>(new Map()); + + // Quelle action est en mode édition + const [editing, setEditing] = useState(null); + const confirm = useMutation({ mutationFn: (body: AiConfirmRequest) => api("/ai/command/confirm", { @@ -37,6 +41,8 @@ export function VoiceConfirmModal({ response, onClose }: Props) { }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["todos"] }); + void queryClient.invalidateQueries({ queryKey: ["agenda"] }); + void queryClient.invalidateQueries({ queryKey: ["health-tab"] }); onClose(); }, }); @@ -58,12 +64,25 @@ export function VoiceConfirmModal({ response, onClose }: Props) { }); } - function handleConfirm() { + function handleEdit(id: string, updated: ProposedAction) { + setEditedArgs((prev) => new Map(prev).set(id, updated)); + } + + async function handleConfirm() { const ids = Array.from(selected); - if (ids.length === 0) { - onClose(); - return; + if (ids.length === 0) { onClose(); return; } + + // Patch les args modifiés avant de confirmer + for (const id of ids) { + const edited = editedArgs.get(id); + if (edited) { + await api(`/ai/actions/${id}/args`, { + method: "PATCH", + body: JSON.stringify({ args: edited.args }), + }); + } } + confirm.mutate({ action_ids: ids }); } @@ -71,16 +90,17 @@ export function VoiceConfirmModal({ response, onClose }: Props) { return (
!confirm.isPending && onClose()} >
e.stopPropagation()} role="dialog" aria-modal="true" > -
+ {/* En-tête */} +
-
- -

+ {/* Transcript */} +

+ +

{response.transcript || Aucun audio reconnu.}

-
+ {/* Actions — scrollable */} +
{hasActions ? ( -
    - {response.actions.map((a) => ( - toggle(a.id)} - /> - ))} +
      + {response.actions.map((a) => { + const effective = editedArgs.get(a.id) ?? a.action; + const isEditing = editing === a.id; + return ( + toggle(a.id)} + onToggleEdit={() => setEditing(isEditing ? null : a.id)} + onEdit={(updated) => handleEdit(a.id, updated)} + isEdited={editedArgs.has(a.id)} + /> + ); + })}
    ) : ( -

    +

    Mistral n'a pas trouvé d'action à proposer. Reformule ou ferme.

    )}
-
+ {/* Footer */} +
{confirm.isError && ( Échec — réessaie @@ -137,7 +168,7 @@ export function VoiceConfirmModal({ response, onClose }: Props) { + + {/* Résumé */} +
{functionLabel(action.action.fn)} + {isEdited && ( + · modifié + )}
-
{summarize(action.action)}
+
{summarize(action.action)}
- + + {/* Bouton éditer */} + {canEdit && ( + + )} +
+ + {/* Formulaire d'édition inline */} + {isEditing && ( + + )} ); } +// ── Helpers ─────────────────────────────────────────────────────────────────── + function functionLabel(fn: ProposedAction["fn"]): string { switch (fn) { - case "create_todo": - return "CRÉER UNE TÂCHE"; - case "add_project_idea": - return "IDÉE DE PROJET"; - case "add_project_step": - return "ÉTAPE DE PROJET"; - case "create_calendar_event": - return "ÉVÉNEMENT AGENDA"; - case "toggle_daily_checkin": - return "CHECK-IN DU JOUR"; + case "create_todo": return "Créer une tâche"; + case "add_project_idea": return "Idée de projet"; + case "add_project_step": return "Étape de projet"; + case "create_calendar_event": return "Événement agenda"; + case "toggle_daily_checkin": return "Check-in du jour"; } } @@ -203,7 +267,7 @@ function summarize(action: ProposedAction): string { switch (action.fn) { case "create_todo": { const parts = [action.args.title]; - if (action.args.due_at) parts.push(`· échéance ${formatDate(action.args.due_at)}`); + if (action.args.due_at) parts.push(`· ${formatDatetime(action.args.due_at)}`); if (typeof action.args.priority === "number") parts.push(`· p${action.args.priority}`); return parts.join(" "); } @@ -212,22 +276,23 @@ function summarize(action: ProposedAction): string { case "add_project_step": return `${action.args.title}${action.args.status ? ` · ${action.args.status}` : ""}`; case "create_calendar_event": - return `${action.args.title} · ${formatDate(action.args.starts_at)} → ${formatDate(action.args.ends_at)}`; + return `${action.args.title} · ${formatDatetime(action.args.starts_at)} → ${formatTime(action.args.ends_at)}`; case "toggle_daily_checkin": return action.args.note ?? "Check-in du jour"; } } -function formatDate(iso: string): string { +function formatDatetime(iso: string): string { try { - const d = new Date(iso); - return d.toLocaleString("fr-FR", { - day: "2-digit", - month: "short", - hour: "2-digit", - minute: "2-digit", + return new Date(iso).toLocaleString("fr-FR", { + day: "2-digit", month: "short", + hour: "2-digit", minute: "2-digit", }); - } catch { - return iso; - } + } catch { return iso; } +} + +function formatTime(iso: string): string { + try { + return new Date(iso).toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" }); + } catch { return iso; } } diff --git a/apps/pwa/src/components/shell/BottomNav.tsx b/apps/pwa/src/components/shell/BottomNav.tsx new file mode 100644 index 0000000..6f807c4 --- /dev/null +++ b/apps/pwa/src/components/shell/BottomNav.tsx @@ -0,0 +1,30 @@ +import { Link } from "@tanstack/react-router"; + +const NAV_ITEMS = [ + { to: "/", label: "Home", symbol: "◎" }, + { to: "/agenda", label: "Agenda", symbol: "◫" }, + { to: "/todos", label: "Todos", symbol: "□" }, + { to: "/jobs", label: "Jobs", symbol: "⚡" }, +] as const; + +export function BottomNav() { + return ( + + ); +} diff --git a/apps/pwa/src/design/BigHeading.tsx b/apps/pwa/src/design/BigHeading.tsx index 12830d9..61425ad 100644 --- a/apps/pwa/src/design/BigHeading.tsx +++ b/apps/pwa/src/design/BigHeading.tsx @@ -13,7 +13,7 @@ export function BigHeading({ as: Tag = "h1", className, children, ...props }: Bi rootRouteImport, } as any) +const AgendaRoute = AgendaRouteImport.update({ + id: '/agenda', + path: '/agenda', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', @@ -37,12 +43,14 @@ const SettingsJobsRoute = SettingsJobsRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/agenda': typeof AgendaRoute '/jobs': typeof JobsRoute '/todos': typeof TodosRoute '/settings/jobs': typeof SettingsJobsRoute } export interface FileRoutesByTo { '/': typeof IndexRoute + '/agenda': typeof AgendaRoute '/jobs': typeof JobsRoute '/todos': typeof TodosRoute '/settings/jobs': typeof SettingsJobsRoute @@ -50,20 +58,22 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/agenda': typeof AgendaRoute '/jobs': typeof JobsRoute '/todos': typeof TodosRoute '/settings/jobs': typeof SettingsJobsRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/jobs' | '/todos' | '/settings/jobs' + fullPaths: '/' | '/agenda' | '/jobs' | '/todos' | '/settings/jobs' fileRoutesByTo: FileRoutesByTo - to: '/' | '/jobs' | '/todos' | '/settings/jobs' - id: '__root__' | '/' | '/jobs' | '/todos' | '/settings/jobs' + to: '/' | '/agenda' | '/jobs' | '/todos' | '/settings/jobs' + id: '__root__' | '/' | '/agenda' | '/jobs' | '/todos' | '/settings/jobs' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + AgendaRoute: typeof AgendaRoute JobsRoute: typeof JobsRoute TodosRoute: typeof TodosRoute SettingsJobsRoute: typeof SettingsJobsRoute @@ -85,6 +95,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof JobsRouteImport parentRoute: typeof rootRouteImport } + '/agenda': { + id: '/agenda' + path: '/agenda' + fullPath: '/agenda' + preLoaderRoute: typeof AgendaRouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -104,6 +121,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + AgendaRoute: AgendaRoute, JobsRoute: JobsRoute, TodosRoute: TodosRoute, SettingsJobsRoute: SettingsJobsRoute, diff --git a/apps/pwa/src/routes/__root.tsx b/apps/pwa/src/routes/__root.tsx index 957184e..27a6948 100644 --- a/apps/pwa/src/routes/__root.tsx +++ b/apps/pwa/src/routes/__root.tsx @@ -1,5 +1,6 @@ import { Outlet, createRootRoute, Link } from "@tanstack/react-router"; import { AccentDot, Label } from "@/design"; +import { BottomNav } from "@/components/shell/BottomNav"; export const Route = createRootRoute({ component: RootLayout, @@ -14,22 +15,30 @@ function RootLayout() { -
-
+ + {/* Padding-bottom mobile = hauteur de la BottomNav (~64px) */} +
-