add agenda
This commit is contained in:
parent
242abdba5d
commit
7de7ef16b9
@ -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("*");
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
100
apps/api/src/modules/agenda/agenda.controller.ts
Normal file
100
apps/api/src/modules/agenda/agenda.controller.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
9
apps/api/src/modules/agenda/agenda.module.ts
Normal file
9
apps/api/src/modules/agenda/agenda.module.ts
Normal file
@ -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 {}
|
||||
373
apps/api/src/modules/agenda/agenda.service.ts
Normal file
373
apps/api/src/modules/agenda/agenda.service.ts
Normal file
@ -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<CalendarEvent[]> {
|
||||
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<CalendarEvent> {
|
||||
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<CalendarEvent> {
|
||||
const set: Partial<typeof calendarEvents.$inferInsert> = {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<GoogleSyncResponse> {
|
||||
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<string> {
|
||||
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<string | null> {
|
||||
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");
|
||||
}
|
||||
@ -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<string, unknown> },
|
||||
): Promise<void> {
|
||||
return this.ai.patchArgs(id, body.args);
|
||||
}
|
||||
|
||||
@Post("command/confirm")
|
||||
confirm(
|
||||
@Body(new ZodPipe(AiConfirmRequest)) body: AiConfirmRequest,
|
||||
|
||||
@ -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<string, unknown>): Promise<void> {
|
||||
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<AiConfirmResponse> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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: {
|
||||
|
||||
58
apps/pwa/src/components/agenda/EventCard.tsx
Normal file
58
apps/pwa/src/components/agenda/EventCard.tsx
Normal file
@ -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 (
|
||||
<article className="flex items-start gap-4 px-4 py-3">
|
||||
{/* Barre couleur + heure */}
|
||||
<div className="flex-shrink-0 flex flex-col items-end w-[4.5rem]">
|
||||
<span className="font-mono text-[10px] text-muted leading-tight text-right">
|
||||
{timeLabel}
|
||||
</span>
|
||||
{event.source === "google" && (
|
||||
<span className="font-mono text-[9px] uppercase tracking-label text-muted mt-0.5">
|
||||
Google
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Séparateur vertical */}
|
||||
<div className="w-px self-stretch bg-ink flex-shrink-0" />
|
||||
|
||||
{/* Contenu */}
|
||||
<div className="flex-1 min-w-0 space-y-0.5">
|
||||
<p className="font-sans text-sm text-ink leading-snug truncate">{event.title}</p>
|
||||
{event.location && (
|
||||
<p className="font-mono text-[10px] text-muted truncate">{event.location}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Supprimer (événements locaux uniquement) */}
|
||||
{onDelete && event.source !== "google" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(event.id)}
|
||||
aria-label="Supprimer"
|
||||
className="flex-shrink-0 font-mono text-[10px] text-muted hover:text-ink transition-colors px-1"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
return new Date(iso).toLocaleTimeString("fr-FR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
122
apps/pwa/src/components/agenda/NewEventForm.tsx
Normal file
122
apps/pwa/src/components/agenda/NewEventForm.tsx
Normal file
@ -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 (
|
||||
<form onSubmit={handleSubmit} className="border border-ink divide-y divide-ink">
|
||||
{/* Titre */}
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder="Titre de l'événement"
|
||||
value={title}
|
||||
onChange={(e) => 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 */}
|
||||
<label className="flex items-center gap-3 px-4 py-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allDay}
|
||||
onChange={(e) => setAllDay(e.target.checked)}
|
||||
className="w-4 h-4 border border-ink bg-transparent accent-ink cursor-pointer"
|
||||
/>
|
||||
<span className="font-mono text-[11px] uppercase tracking-label text-muted">
|
||||
Toute la journée
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Dates */}
|
||||
{!allDay && (
|
||||
<div className="grid grid-cols-2 divide-x divide-ink">
|
||||
<div className="px-4 py-3 space-y-1">
|
||||
<span className="font-mono text-[9px] uppercase tracking-label text-muted block">Début</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={startsAt}
|
||||
onChange={(e) => setStartsAt(e.target.value)}
|
||||
className="w-full bg-transparent font-sans text-sm text-ink outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-4 py-3 space-y-1">
|
||||
<span className="font-mono text-[9px] uppercase tracking-label text-muted block">Fin</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={endsAt}
|
||||
onChange={(e) => setEndsAt(e.target.value)}
|
||||
className="w-full bg-transparent font-sans text-sm text-ink outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lieu */}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Lieu (optionnel)"
|
||||
value={location}
|
||||
onChange={(e) => 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 */}
|
||||
<div className="flex items-center justify-end gap-3 px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="font-mono text-[11px] uppercase tracking-label text-muted hover:text-ink transition-colors"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!title.trim() || isPending}
|
||||
className="border border-ink px-4 py-2 font-mono text-[11px] uppercase tracking-label text-ink transition-colors hover:bg-ink hover:text-bg disabled:opacity-40"
|
||||
>
|
||||
Créer
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
96
apps/pwa/src/components/agenda/WeekStrip.tsx
Normal file
96
apps/pwa/src/components/agenda/WeekStrip.tsx
Normal file
@ -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 (
|
||||
<div className="border border-ink">
|
||||
{/* Navigation semaine */}
|
||||
<div className="flex items-center justify-between border-b border-ink px-4 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPrevWeek}
|
||||
className="font-mono text-[11px] uppercase tracking-label text-muted hover:text-ink transition-colors"
|
||||
>
|
||||
← Préc.
|
||||
</button>
|
||||
<span className="font-mono text-[11px] uppercase tracking-label text-ink">
|
||||
{formatWeekLabel(first, last)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNextWeek}
|
||||
className="font-mono text-[11px] uppercase tracking-label text-muted hover:text-ink transition-colors"
|
||||
>
|
||||
Suiv. →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Jours */}
|
||||
<div className="grid grid-cols-7 divide-x divide-ink">
|
||||
{weekDays.map((day) => {
|
||||
const isToday = day.getTime() === today.getTime();
|
||||
const isSelected = day.getTime() === normalize(selectedDate).getTime();
|
||||
return (
|
||||
<button
|
||||
key={day.toISOString()}
|
||||
type="button"
|
||||
onClick={() => onSelect(day)}
|
||||
className={[
|
||||
"flex flex-col items-center gap-1 py-3 transition-colors",
|
||||
isSelected
|
||||
? "bg-ink text-bg"
|
||||
: "text-ink hover:bg-ink/5",
|
||||
].join(" ")}
|
||||
>
|
||||
<span className="font-mono text-[9px] uppercase tracking-label opacity-70">
|
||||
{DAYS_SHORT[day.getDay()]}
|
||||
</span>
|
||||
<span className={`font-sans text-sm font-light leading-none ${isToday && !isSelected ? "text-accent" : ""}`}>
|
||||
{day.getDate()}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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)}`;
|
||||
}
|
||||
167
apps/pwa/src/components/ai/ActionEditor.tsx
Normal file
167
apps/pwa/src/components/ai/ActionEditor.tsx
Normal file
@ -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 <CalendarEventEditor action={action} onChange={onChange} />;
|
||||
case "create_todo":
|
||||
return <TodoEditor action={action} onChange={onChange} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Éditeur d'événement calendrier ───────────────────────────────────────────
|
||||
|
||||
function CalendarEventEditor({
|
||||
action,
|
||||
onChange,
|
||||
}: {
|
||||
action: Extract<ProposedAction, { fn: "create_calendar_event" }>;
|
||||
onChange: (a: ProposedAction) => void;
|
||||
}) {
|
||||
const args = action.args;
|
||||
|
||||
function patch(partial: Partial<typeof args>) {
|
||||
onChange({ ...action, args: { ...args, ...partial } });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t border-ink divide-y divide-ink">
|
||||
{/* Titre */}
|
||||
<div className="px-3 py-2 space-y-1">
|
||||
<span className="font-mono text-[9px] uppercase tracking-label text-muted block">Titre</span>
|
||||
<input
|
||||
type="text"
|
||||
value={args.title}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Début / Fin */}
|
||||
<div className="grid grid-cols-2 divide-x divide-ink">
|
||||
<div className="px-3 py-2 space-y-1">
|
||||
<span className="font-mono text-[9px] uppercase tracking-label text-muted block">Début</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={toLocalInput(args.starts_at)}
|
||||
onChange={(e) => patch({ starts_at: fromLocalInput(e.target.value) })}
|
||||
className="w-full bg-transparent font-sans text-sm text-ink outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-3 py-2 space-y-1">
|
||||
<span className="font-mono text-[9px] uppercase tracking-label text-muted block">Fin</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={toLocalInput(args.ends_at)}
|
||||
onChange={(e) => patch({ ends_at: fromLocalInput(e.target.value) })}
|
||||
className="w-full bg-transparent font-sans text-sm text-ink outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lieu */}
|
||||
<div className="px-3 py-2 space-y-1">
|
||||
<span className="font-mono text-[9px] uppercase tracking-label text-muted block">Lieu (optionnel)</span>
|
||||
<input
|
||||
type="text"
|
||||
value={args.location ?? ""}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Éditeur de tâche ─────────────────────────────────────────────────────────
|
||||
|
||||
function TodoEditor({
|
||||
action,
|
||||
onChange,
|
||||
}: {
|
||||
action: Extract<ProposedAction, { fn: "create_todo" }>;
|
||||
onChange: (a: ProposedAction) => void;
|
||||
}) {
|
||||
const args = action.args;
|
||||
|
||||
function patch(partial: Partial<typeof args>) {
|
||||
onChange({ ...action, args: { ...args, ...partial } });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t border-ink divide-y divide-ink">
|
||||
{/* Titre */}
|
||||
<div className="px-3 py-2 space-y-1">
|
||||
<span className="font-mono text-[9px] uppercase tracking-label text-muted block">Titre</span>
|
||||
<input
|
||||
type="text"
|
||||
value={args.title}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Échéance */}
|
||||
<div className="px-3 py-2 space-y-1">
|
||||
<span className="font-mono text-[9px] uppercase tracking-label text-muted block">Échéance (optionnel)</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={args.due_at ? toLocalInput(args.due_at) : ""}
|
||||
onChange={(e) =>
|
||||
patch({ due_at: e.target.value ? fromLocalInput(e.target.value) : undefined })
|
||||
}
|
||||
className="w-full bg-transparent font-sans text-sm text-ink outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Priorité */}
|
||||
<div className="px-3 py-2 flex items-center gap-4">
|
||||
<span className="font-mono text-[9px] uppercase tracking-label text-muted">Priorité</span>
|
||||
{([0, 1, 2, 3] as const).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => patch({ priority: p })}
|
||||
className={[
|
||||
"font-mono text-[10px] uppercase tracking-label px-2 py-1 border transition-colors",
|
||||
args.priority === p
|
||||
? "border-ink bg-ink text-bg"
|
||||
: "border-ink text-muted hover:text-ink",
|
||||
].join(" ")}
|
||||
>
|
||||
{p === 0 ? "Bas" : p === 1 ? "Normal" : p === 2 ? "Haut" : "Urgent"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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
|
||||
}
|
||||
@ -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<Set<string>>(initialIds);
|
||||
|
||||
// Args édités localement avant confirmation
|
||||
const [editedArgs, setEditedArgs] = useState<Map<string, ProposedAction>>(new Map());
|
||||
|
||||
// Quelle action est en mode édition
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
|
||||
const confirm = useMutation({
|
||||
mutationFn: (body: AiConfirmRequest) =>
|
||||
api<AiConfirmResponse>("/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 (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-ink/40 flex items-center justify-center p-4"
|
||||
className="fixed inset-0 z-50 bg-ink/40 flex items-end sm:items-center justify-center p-0 sm:p-4"
|
||||
onClick={() => !confirm.isPending && onClose()}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-xl bg-bg border-2 border-ink"
|
||||
className="w-full sm:max-w-xl bg-bg border-t-2 sm:border-2 border-ink max-h-[90dvh] flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<header className="border-b border-ink px-4 py-3 flex items-center justify-between">
|
||||
{/* En-tête */}
|
||||
<header className="border-b border-ink px-4 py-3 flex items-center justify-between flex-shrink-0">
|
||||
<Label prefix="[ VOICE ]">CONFIRMATION</Label>
|
||||
<button
|
||||
type="button"
|
||||
@ -93,34 +113,45 @@ export function VoiceConfirmModal({ response, onClose }: Props) {
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section className="px-4 py-4 border-b border-ink space-y-2">
|
||||
<Label>TRANSCRIPT</Label>
|
||||
<p className="font-sans text-sm leading-6 text-ink">
|
||||
{/* Transcript */}
|
||||
<section className="px-4 py-3 border-b border-ink flex-shrink-0">
|
||||
<Label className="mb-1">TRANSCRIPT</Label>
|
||||
<p className="font-sans text-sm leading-6 text-ink mt-1">
|
||||
{response.transcript || <span className="text-muted">Aucun audio reconnu.</span>}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="px-4 py-4 space-y-3">
|
||||
{/* Actions — scrollable */}
|
||||
<section className="overflow-y-auto flex-1 px-4 py-3 space-y-2">
|
||||
<Label>{hasActions ? "ACTIONS PROPOSÉES" : "AUCUNE ACTION"}</Label>
|
||||
{hasActions ? (
|
||||
<ul className="space-y-2">
|
||||
{response.actions.map((a) => (
|
||||
<ActionRow
|
||||
key={a.id}
|
||||
action={a}
|
||||
selected={selected.has(a.id)}
|
||||
onToggle={() => toggle(a.id)}
|
||||
/>
|
||||
))}
|
||||
<ul className="space-y-2 mt-2">
|
||||
{response.actions.map((a) => {
|
||||
const effective = editedArgs.get(a.id) ?? a.action;
|
||||
const isEditing = editing === a.id;
|
||||
return (
|
||||
<ActionRow
|
||||
key={a.id}
|
||||
action={{ ...a, action: effective }}
|
||||
selected={selected.has(a.id)}
|
||||
isEditing={isEditing}
|
||||
onToggle={() => toggle(a.id)}
|
||||
onToggleEdit={() => setEditing(isEditing ? null : a.id)}
|
||||
onEdit={(updated) => handleEdit(a.id, updated)}
|
||||
isEdited={editedArgs.has(a.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="font-sans text-sm text-muted">
|
||||
<p className="font-sans text-sm text-muted mt-2">
|
||||
Mistral n'a pas trouvé d'action à proposer. Reformule ou ferme.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<footer className="border-t border-ink px-4 py-3 flex items-center justify-between gap-3">
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-ink px-4 py-3 flex items-center justify-between gap-3 flex-shrink-0">
|
||||
{confirm.isError && (
|
||||
<span className="font-mono text-[10px] uppercase tracking-label text-accent">
|
||||
Échec — réessaie
|
||||
@ -137,7 +168,7 @@ export function VoiceConfirmModal({ response, onClose }: Props) {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
onClick={() => void handleConfirm()}
|
||||
disabled={!hasActions || selected.size === 0 || confirm.isPending}
|
||||
className="border border-ink bg-ink px-4 py-2 font-mono text-[11px] uppercase tracking-label text-bg hover:bg-accent hover:border-accent disabled:opacity-40"
|
||||
>
|
||||
@ -150,52 +181,85 @@ export function VoiceConfirmModal({ response, onClose }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── ActionRow ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function ActionRow({
|
||||
action,
|
||||
selected,
|
||||
isEditing,
|
||||
isEdited,
|
||||
onToggle,
|
||||
onToggleEdit,
|
||||
onEdit,
|
||||
}: {
|
||||
action: ProposedActionWithId;
|
||||
selected: boolean;
|
||||
isEditing: boolean;
|
||||
isEdited: boolean;
|
||||
onToggle: () => void;
|
||||
onToggleEdit: () => void;
|
||||
onEdit: (updated: ProposedAction) => void;
|
||||
}) {
|
||||
const canEdit = action.action.fn === "create_calendar_event" || action.action.fn === "create_todo";
|
||||
|
||||
return (
|
||||
<li className="border border-ink">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-start gap-3 px-3 py-2 text-left"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 flex-shrink-0 w-4 h-4 border border-ink",
|
||||
selected ? "bg-ink" : "bg-transparent",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<div className="flex items-start gap-3 px-3 py-2">
|
||||
{/* Checkbox */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-pressed={selected}
|
||||
className="mt-0.5 flex-shrink-0 w-4 h-4 border border-ink"
|
||||
>
|
||||
<span className={cn("block w-full h-full", selected ? "bg-ink" : "bg-transparent")} />
|
||||
</button>
|
||||
|
||||
{/* Résumé */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-mono text-[10px] uppercase tracking-label text-muted">
|
||||
{functionLabel(action.action.fn)}
|
||||
{isEdited && (
|
||||
<span className="ml-2 text-accent">· modifié</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="font-sans text-sm text-ink">{summarize(action.action)}</div>
|
||||
<div className="font-sans text-sm text-ink mt-0.5">{summarize(action.action)}</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Bouton éditer */}
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleEdit}
|
||||
className={cn(
|
||||
"flex-shrink-0 font-mono text-[10px] uppercase tracking-label px-2 py-1 border transition-colors",
|
||||
isEditing
|
||||
? "border-ink bg-ink text-bg"
|
||||
: "border-ink text-muted hover:text-ink",
|
||||
)}
|
||||
>
|
||||
{isEditing ? "Fermer" : "Éditer"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Formulaire d'édition inline */}
|
||||
{isEditing && (
|
||||
<ActionEditor action={action.action} onChange={onEdit} />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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; }
|
||||
}
|
||||
|
||||
30
apps/pwa/src/components/shell/BottomNav.tsx
Normal file
30
apps/pwa/src/components/shell/BottomNav.tsx
Normal file
@ -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 (
|
||||
<nav
|
||||
className="fixed bottom-0 inset-x-0 z-40 flex border-t border-ink bg-bg md:hidden"
|
||||
style={{ paddingBottom: "env(safe-area-inset-bottom, 0px)" }}
|
||||
>
|
||||
{NAV_ITEMS.map(({ to, label, symbol }) => (
|
||||
<Link
|
||||
key={to}
|
||||
to={to}
|
||||
className="flex flex-1 flex-col items-center gap-0.5 py-3 font-mono text-muted transition-colors hover:text-ink [&.active]:text-ink"
|
||||
activeOptions={{ exact: to === "/" }}
|
||||
activeProps={{ className: "active" }}
|
||||
>
|
||||
<span className="text-base leading-none">{symbol}</span>
|
||||
<span className="text-[9px] uppercase tracking-label">{label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@ -13,7 +13,7 @@ export function BigHeading({ as: Tag = "h1", className, children, ...props }: Bi
|
||||
<Tag
|
||||
className={cn(
|
||||
"font-sans font-light tracking-tightest text-ink",
|
||||
"text-[clamp(2.5rem,8vw,6rem)] leading-[0.95]",
|
||||
"text-[clamp(1.8rem,8vw,6rem)] leading-[0.95]",
|
||||
"[&_em]:not-italic [&_em]:text-accent [&_em]:italic",
|
||||
className,
|
||||
)}
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as TodosRouteImport } from './routes/todos'
|
||||
import { Route as JobsRouteImport } from './routes/jobs'
|
||||
import { Route as AgendaRouteImport } from './routes/agenda'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as SettingsJobsRouteImport } from './routes/settings.jobs'
|
||||
|
||||
@ -24,6 +25,11 @@ const JobsRoute = JobsRouteImport.update({
|
||||
path: '/jobs',
|
||||
getParentRoute: () => 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,
|
||||
|
||||
@ -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() {
|
||||
<AccentDot />
|
||||
<Label className="text-ink">ORDINARTHUR-OS</Label>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-4">
|
||||
|
||||
{/* Nav desktop uniquement */}
|
||||
<nav className="hidden md:flex items-center gap-4">
|
||||
<NavLink to="/">Dashboard</NavLink>
|
||||
<NavLink to="/jobs">Jobs</NavLink>
|
||||
<NavLink to="/agenda">Agenda</NavLink>
|
||||
<NavLink to="/todos">Todos</NavLink>
|
||||
<NavLink to="/jobs">Jobs</NavLink>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 mx-auto max-w-7xl w-full px-4 py-8">
|
||||
|
||||
{/* Padding-bottom mobile = hauteur de la BottomNav (~64px) */}
|
||||
<main className="flex-1 mx-auto max-w-7xl w-full px-4 py-6 md:py-8 pb-20 md:pb-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
<footer className="border-t border-ink">
|
||||
|
||||
<footer className="hidden md:block border-t border-ink">
|
||||
<div className="mx-auto max-w-7xl flex items-center justify-between px-4 py-3">
|
||||
<Label>v0.0.0 · phase 1</Label>
|
||||
<Label>arthurbarre.fr</Label>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<BottomNav />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
228
apps/pwa/src/routes/agenda.tsx
Normal file
228
apps/pwa/src/routes/agenda.tsx
Normal file
@ -0,0 +1,228 @@
|
||||
import { useState } from "react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { CalendarEvent, CalendarEventCreateDto } from "@ordinarthur-os/shared";
|
||||
import { api } from "@/api/client";
|
||||
import { BigHeading, Label } from "@/design";
|
||||
import { WeekStrip } from "@/components/agenda/WeekStrip";
|
||||
import { EventCard } from "@/components/agenda/EventCard";
|
||||
import { NewEventForm } from "@/components/agenda/NewEventForm";
|
||||
|
||||
export const Route = createFileRoute("/agenda")({ component: AgendaPage });
|
||||
|
||||
function AgendaPage() {
|
||||
const qc = useQueryClient();
|
||||
const [selectedDate, setSelectedDate] = useState(() => startOfDay(new Date()));
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
|
||||
const { data: googleStatus, refetch: refetchStatus } = useQuery({
|
||||
queryKey: ["agenda", "google-status"],
|
||||
queryFn: () => api<{ connected: boolean }>("/agenda/google/status"),
|
||||
});
|
||||
const isConnected = googleStatus?.connected ?? false;
|
||||
|
||||
// ── Borne de la semaine courante pour le fetch ──────────────────────────
|
||||
const weekStart = getMonday(selectedDate);
|
||||
const weekEnd = new Date(weekStart);
|
||||
weekEnd.setDate(weekStart.getDate() + 6);
|
||||
weekEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
const { data: events = [], isLoading } = useQuery({
|
||||
queryKey: ["agenda", weekStart.toISOString()],
|
||||
queryFn: () =>
|
||||
api<CalendarEvent[]>(
|
||||
`/agenda/events?from=${weekStart.toISOString()}&to=${weekEnd.toISOString()}`,
|
||||
),
|
||||
});
|
||||
|
||||
const createEvent = useMutation({
|
||||
mutationFn: (dto: CalendarEventCreateDto) =>
|
||||
api<CalendarEvent>("/agenda/events", { method: "POST", body: JSON.stringify(dto) }),
|
||||
onSuccess: () => {
|
||||
setShowForm(false);
|
||||
void qc.invalidateQueries({ queryKey: ["agenda"] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteEvent = useMutation({
|
||||
mutationFn: (id: string) => api<void>(`/agenda/events/${id}`, { method: "DELETE" }),
|
||||
onSuccess: () => void qc.invalidateQueries({ queryKey: ["agenda"] }),
|
||||
});
|
||||
|
||||
async function handleSync() {
|
||||
setSyncing(true);
|
||||
try {
|
||||
await api("/agenda/google/sync", { method: "POST" });
|
||||
await qc.invalidateQueries({ queryKey: ["agenda"] });
|
||||
void refetchStatus();
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConnectGoogle() {
|
||||
setConnecting(true);
|
||||
try {
|
||||
const { url } = await api<{ url: string }>("/agenda/google/oauth/start");
|
||||
window.location.href = url;
|
||||
} catch {
|
||||
setConnecting(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Événements du jour sélectionné ─────────────────────────────────────
|
||||
const dayEvents = events
|
||||
.filter((ev) => isSameDay(new Date(ev.starts_at), selectedDate))
|
||||
.sort((a, b) => new Date(a.starts_at).getTime() - new Date(b.starts_at).getTime());
|
||||
|
||||
const selectedLabel = selectedDate.toLocaleDateString("fr-FR", {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
});
|
||||
|
||||
function shiftWeek(delta: number) {
|
||||
const next = new Date(selectedDate);
|
||||
next.setDate(next.getDate() + delta * 7);
|
||||
setSelectedDate(startOfDay(next));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 md:space-y-10">
|
||||
{/* ── En-tête ──────────────────────────────────────────────────────── */}
|
||||
<section className="space-y-4">
|
||||
<Label prefix="[ 04 ]">AGENDA</Label>
|
||||
<BigHeading>
|
||||
Agenda <em>personnel.</em>
|
||||
</BigHeading>
|
||||
</section>
|
||||
|
||||
{/* ── Bandeau semaine ──────────────────────────────────────────────── */}
|
||||
<WeekStrip
|
||||
selectedDate={selectedDate}
|
||||
onSelect={setSelectedDate}
|
||||
onPrevWeek={() => shiftWeek(-1)}
|
||||
onNextWeek={() => shiftWeek(1)}
|
||||
/>
|
||||
|
||||
{/* ── Événements du jour ───────────────────────────────────────────── */}
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between border-b border-ink pb-3">
|
||||
<Label prefix="[ 01 ]">{capitalize(selectedLabel)}</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSync}
|
||||
disabled={syncing}
|
||||
className="font-mono text-[10px] uppercase tracking-label text-muted hover:text-ink transition-colors disabled:opacity-40"
|
||||
>
|
||||
{syncing ? "Sync…" : "↻ Google"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowForm((v) => !v)}
|
||||
className="font-mono text-[11px] uppercase tracking-label text-ink border border-ink px-3 py-1.5 hover:bg-ink hover:text-bg transition-colors"
|
||||
>
|
||||
{showForm ? "Annuler" : "+ Événement"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<NewEventForm
|
||||
defaultDate={selectedDate}
|
||||
onSubmit={(dto) => createEvent.mutate(dto)}
|
||||
onCancel={() => setShowForm(false)}
|
||||
isPending={createEvent.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<EmptyState text="Chargement…" />
|
||||
) : dayEvents.length === 0 ? (
|
||||
<EmptyState text="Aucun événement ce jour." />
|
||||
) : (
|
||||
<ul className="border border-ink divide-y divide-ink">
|
||||
{dayEvents.map((ev) => (
|
||||
<li key={ev.id}>
|
||||
<EventCard
|
||||
event={ev}
|
||||
onDelete={(id) => deleteEvent.mutate(id)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── Connexion Google ─────────────────────────────────────────────── */}
|
||||
<section className="border border-ink px-4 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label>Google Calendar</Label>
|
||||
{isConnected && (
|
||||
<span className="font-mono text-[10px] uppercase tracking-label text-accent">
|
||||
● Connecté
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isConnected ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConnectGoogle}
|
||||
disabled={connecting}
|
||||
className="font-mono text-[11px] uppercase tracking-label text-muted hover:text-ink transition-colors disabled:opacity-40"
|
||||
>
|
||||
{connecting ? "Redirection…" : "Reconnecter"}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConnectGoogle}
|
||||
disabled={connecting}
|
||||
className="font-mono text-[11px] uppercase tracking-label border border-ink px-3 py-1.5 text-ink hover:bg-ink hover:text-bg transition-colors disabled:opacity-40"
|
||||
>
|
||||
{connecting ? "Redirection…" : "Connecter →"}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="border border-ink px-4 py-8">
|
||||
<p className="font-sans text-sm text-muted">{text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function startOfDay(d: Date): Date {
|
||||
const c = new Date(d);
|
||||
c.setHours(0, 0, 0, 0);
|
||||
return c;
|
||||
}
|
||||
|
||||
function getMonday(d: Date): Date {
|
||||
const c = startOfDay(d);
|
||||
const day = c.getDay() === 0 ? 6 : c.getDay() - 1;
|
||||
c.setDate(c.getDate() - day);
|
||||
return c;
|
||||
}
|
||||
|
||||
function isSameDay(a: Date, b: Date): boolean {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
function capitalize(s: string): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
@ -19,7 +19,7 @@ function Dashboard() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-16">
|
||||
<div className="space-y-8 md:space-y-16">
|
||||
<section className="space-y-6">
|
||||
<Label prefix="[ 00 ]">ORDINARTHUR-OS</Label>
|
||||
<BigHeading>
|
||||
|
||||
@ -57,7 +57,7 @@ function TodosPage() {
|
||||
const done = data.filter((t) => t.status === "done");
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<div className="space-y-6 md:space-y-10">
|
||||
<section className="space-y-4">
|
||||
<Label prefix="[ 02 ]">PHASE 2 · TODOS</Label>
|
||||
<BigHeading>
|
||||
|
||||
29
packages/db/migrations/0005_agenda.sql
Normal file
29
packages/db/migrations/0005_agenda.sql
Normal file
@ -0,0 +1,29 @@
|
||||
-- 0005_agenda.sql — Phase 4
|
||||
-- Événements calendrier (source locale ou sync Google) + token OAuth Google (single-row).
|
||||
set search_path to ordinarthur_os, public;
|
||||
|
||||
create table if not exists ordinarthur_os.calendar_events (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
google_event_id text unique,
|
||||
title text not null,
|
||||
description text,
|
||||
location text,
|
||||
starts_at timestamptz not null,
|
||||
ends_at timestamptz not null,
|
||||
all_day boolean not null default false,
|
||||
source text not null default 'ordinarthur-os',
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists calendar_events_starts_at_idx
|
||||
on ordinarthur_os.calendar_events(starts_at);
|
||||
|
||||
-- Single-row pour Arthur (id forcé à 1 par le check)
|
||||
create table if not exists ordinarthur_os.google_oauth_tokens (
|
||||
id smallint primary key default 1 check (id = 1),
|
||||
access_token text not null,
|
||||
refresh_token text not null,
|
||||
expires_at timestamptz not null,
|
||||
calendar_id text
|
||||
);
|
||||
@ -36,6 +36,13 @@
|
||||
"when": 1745107200000,
|
||||
"tag": "0004_daily_checkins",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1745193600000,
|
||||
"tag": "0005_agenda",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
35
packages/db/src/schema/calendar_events.ts
Normal file
35
packages/db/src/schema/calendar_events.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { boolean, index, smallint, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||
import { appSchema } from "./_schema";
|
||||
|
||||
export const calendarEvents = appSchema.table(
|
||||
"calendar_events",
|
||||
{
|
||||
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||
googleEventId: text("google_event_id").unique(),
|
||||
title: text("title").notNull(),
|
||||
description: text("description"),
|
||||
location: text("location"),
|
||||
startsAt: timestamp("starts_at", { withTimezone: true }).notNull(),
|
||||
endsAt: timestamp("ends_at", { withTimezone: true }).notNull(),
|
||||
allDay: boolean("all_day").notNull().default(false),
|
||||
source: text("source").notNull().default("ordinarthur-os"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
startsAtIdx: index("calendar_events_starts_at_idx").on(t.startsAt),
|
||||
}),
|
||||
);
|
||||
|
||||
export const googleOAuthTokens = appSchema.table("google_oauth_tokens", {
|
||||
id: smallint("id").primaryKey().default(1),
|
||||
accessToken: text("access_token").notNull(),
|
||||
refreshToken: text("refresh_token").notNull(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
calendarId: text("calendar_id"),
|
||||
});
|
||||
|
||||
export type CalendarEventRow = typeof calendarEvents.$inferSelect;
|
||||
export type CalendarEventInsert = typeof calendarEvents.$inferInsert;
|
||||
export type GoogleOAuthTokenRow = typeof googleOAuthTokens.$inferSelect;
|
||||
@ -3,3 +3,4 @@ export * from "./jobs";
|
||||
export * from "./todos";
|
||||
export * from "./ai_actions";
|
||||
export * from "./daily_checkins";
|
||||
export * from "./calendar_events";
|
||||
|
||||
@ -220,7 +220,7 @@ export const ProposedAction = z.discriminatedUnion("fn", [
|
||||
args: z.object({
|
||||
title: z.string(),
|
||||
description: z.string().optional(),
|
||||
due_at: z.string().datetime().optional(),
|
||||
due_at: z.string().datetime({ offset: true }).optional(),
|
||||
priority: z.number().int().min(0).max(3).optional(),
|
||||
project_id: z.string().uuid().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
@ -245,8 +245,8 @@ export const ProposedAction = z.discriminatedUnion("fn", [
|
||||
fn: z.literal("create_calendar_event"),
|
||||
args: z.object({
|
||||
title: z.string(),
|
||||
starts_at: z.string().datetime(),
|
||||
ends_at: z.string().datetime(),
|
||||
starts_at: z.string().datetime({ offset: true }),
|
||||
ends_at: z.string().datetime({ offset: true }),
|
||||
location: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
}),
|
||||
@ -307,6 +307,50 @@ export const AiConfirmResponse = z.object({
|
||||
});
|
||||
export type AiConfirmResponse = z.infer<typeof AiConfirmResponse>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 4 — Agenda / Calendar events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const CalendarEvent = z.object({
|
||||
id: z.string().uuid(),
|
||||
google_event_id: z.string().nullable(),
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
location: z.string().nullable(),
|
||||
starts_at: z.string(),
|
||||
ends_at: z.string(),
|
||||
all_day: z.boolean(),
|
||||
source: z.string(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
});
|
||||
export type CalendarEvent = z.infer<typeof CalendarEvent>;
|
||||
|
||||
export const CalendarEventCreateDto = z.object({
|
||||
title: z.string().min(1),
|
||||
description: z.string().nullable().optional(),
|
||||
location: z.string().nullable().optional(),
|
||||
starts_at: z.string().datetime({ offset: true }),
|
||||
ends_at: z.string().datetime({ offset: true }),
|
||||
all_day: z.boolean().optional(),
|
||||
});
|
||||
export type CalendarEventCreateDto = z.infer<typeof CalendarEventCreateDto>;
|
||||
|
||||
export const CalendarEventPatchDto = CalendarEventCreateDto.partial();
|
||||
export type CalendarEventPatchDto = z.infer<typeof CalendarEventPatchDto>;
|
||||
|
||||
export const CalendarEventListQuery = z.object({
|
||||
from: z.string().datetime().optional(),
|
||||
to: z.string().datetime().optional(),
|
||||
});
|
||||
export type CalendarEventListQuery = z.infer<typeof CalendarEventListQuery>;
|
||||
|
||||
export const GoogleSyncResponse = z.object({
|
||||
inserted: z.number().int(),
|
||||
updated: z.number().int(),
|
||||
});
|
||||
export type GoogleSyncResponse = z.infer<typeof GoogleSyncResponse>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 7 — Daily check-in (médocs + note)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user