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 { TodosModule } from "./modules/todos/todos.module";
|
||||||
import { AiModule } from "./modules/ai/ai.module";
|
import { AiModule } from "./modules/ai/ai.module";
|
||||||
import { HealthTabModule } from "./modules/health-tab/health-tab.module";
|
import { HealthTabModule } from "./modules/health-tab/health-tab.module";
|
||||||
|
import { AgendaModule } from "./modules/agenda/agenda.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -19,6 +20,7 @@ import { HealthTabModule } from "./modules/health-tab/health-tab.module";
|
|||||||
TodosModule,
|
TodosModule,
|
||||||
AiModule,
|
AiModule,
|
||||||
HealthTabModule,
|
HealthTabModule,
|
||||||
|
AgendaModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements NestModule {
|
export class AppModule implements NestModule {
|
||||||
@ -27,9 +29,8 @@ export class AppModule implements NestModule {
|
|||||||
.apply(BearerMiddleware)
|
.apply(BearerMiddleware)
|
||||||
.exclude(
|
.exclude(
|
||||||
{ path: "health", method: RequestMethod.GET },
|
{ path: "health", method: RequestMethod.GET },
|
||||||
// Endpoints publics (signés autrement) ajoutés en Phase 4/6 :
|
{ path: "agenda/ical/:secret", method: RequestMethod.GET },
|
||||||
// { path: "telegram/webhook", method: RequestMethod.POST },
|
{ path: "agenda/google/oauth/callback", method: RequestMethod.GET },
|
||||||
// { path: "agenda/ical/:secret.ics", method: RequestMethod.GET },
|
|
||||||
)
|
)
|
||||||
.forRoutes("*");
|
.forRoutes("*");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,6 +27,8 @@ const EnvSchema = z.object({
|
|||||||
GROQ_API_KEY: z.string().optional(),
|
GROQ_API_KEY: z.string().optional(),
|
||||||
GROQ_STT_MODEL: z.string().default("whisper-large-v3-turbo"),
|
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_ID: z.string().optional(),
|
||||||
GOOGLE_OAUTH_CLIENT_SECRET: z.string().optional(),
|
GOOGLE_OAUTH_CLIENT_SECRET: z.string().optional(),
|
||||||
GOOGLE_OAUTH_REDIRECT_URI: z.string().url().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,
|
BadRequestException,
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
HttpCode,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Req,
|
Req,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
@ -42,6 +46,15 @@ export class AiController {
|
|||||||
return this.ai.voice(body);
|
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")
|
@Post("command/confirm")
|
||||||
confirm(
|
confirm(
|
||||||
@Body(new ZodPipe(AiConfirmRequest)) body: AiConfirmRequest,
|
@Body(new ZodPipe(AiConfirmRequest)) body: AiConfirmRequest,
|
||||||
|
|||||||
@ -9,12 +9,12 @@ import {
|
|||||||
ProposedActionWithId,
|
ProposedActionWithId,
|
||||||
TodoCreateDto,
|
TodoCreateDto,
|
||||||
} from "@ordinarthur-os/shared";
|
} 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 { InjectDb, type Db } from "@/db/db.module";
|
||||||
import { MistralClient } from "./mistral.client";
|
import { MistralClient } from "./mistral.client";
|
||||||
import { GroqClient } from "./groq.client";
|
import { GroqClient } from "./groq.client";
|
||||||
|
|
||||||
const { aiActions, todos } = schema;
|
const { aiActions, todos, calendarEvents, dailyCheckins } = schema;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AiService {
|
export class AiService {
|
||||||
@ -46,6 +46,17 @@ export class AiService {
|
|||||||
return { transcript, actions };
|
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> {
|
async confirm(actionIds: string[]): Promise<AiConfirmResponse> {
|
||||||
const rows = await this.db
|
const rows = await this.db
|
||||||
.select()
|
.select()
|
||||||
@ -178,9 +189,42 @@ export class AiService {
|
|||||||
|
|
||||||
case "add_project_idea":
|
case "add_project_idea":
|
||||||
case "add_project_step":
|
case "add_project_step":
|
||||||
case "create_calendar_event":
|
throw new Error(`Fonction "${action.fn}" pas encore câblée (phase 3)`);
|
||||||
case "toggle_daily_checkin":
|
|
||||||
throw new Error(`Fonction "${action.fn}" pas encore câblée (phase ultérieure)`);
|
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.
|
const SYSTEM_PROMPT = `Tu es l'assistant personnel d'Arthur dans ordinarthur-os.
|
||||||
|
|
||||||
- Réponds UNIQUEMENT en français.
|
- 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.
|
- 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.
|
- 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.
|
- 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()
|
@Injectable()
|
||||||
export class MistralClient {
|
export class MistralClient {
|
||||||
@ -142,7 +150,11 @@ export class MistralClient {
|
|||||||
throw new ServiceUnavailableException("MISTRAL_API_KEY non configurée");
|
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", {
|
const res = await fetch("https://api.mistral.ai/v1/chat/completions", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
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 { api } from "@/api/client";
|
||||||
import { Label } from "@/design";
|
import { Label } from "@/design";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
|
import { ActionEditor } from "./ActionEditor";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
response: AiVoiceResponse;
|
response: AiVoiceResponse;
|
||||||
onClose: () => void;
|
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) {
|
export function VoiceConfirmModal({ response, onClose }: Props) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Actions sélectionnées (toutes par défaut)
|
||||||
const initialIds = useMemo(
|
const initialIds = useMemo(
|
||||||
() => new Set(response.actions.map((a) => a.id)),
|
() => new Set(response.actions.map((a) => a.id)),
|
||||||
[response.actions],
|
[response.actions],
|
||||||
);
|
);
|
||||||
const [selected, setSelected] = useState<Set<string>>(initialIds);
|
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({
|
const confirm = useMutation({
|
||||||
mutationFn: (body: AiConfirmRequest) =>
|
mutationFn: (body: AiConfirmRequest) =>
|
||||||
api<AiConfirmResponse>("/ai/command/confirm", {
|
api<AiConfirmResponse>("/ai/command/confirm", {
|
||||||
@ -37,6 +41,8 @@ export function VoiceConfirmModal({ response, onClose }: Props) {
|
|||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ["todos"] });
|
void queryClient.invalidateQueries({ queryKey: ["todos"] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["agenda"] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["health-tab"] });
|
||||||
onClose();
|
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);
|
const ids = Array.from(selected);
|
||||||
if (ids.length === 0) {
|
if (ids.length === 0) { onClose(); return; }
|
||||||
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 });
|
confirm.mutate({ action_ids: ids });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -71,16 +90,17 @@ export function VoiceConfirmModal({ response, onClose }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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()}
|
onClick={() => !confirm.isPending && onClose()}
|
||||||
>
|
>
|
||||||
<div
|
<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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
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>
|
<Label prefix="[ VOICE ]">CONFIRMATION</Label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -93,34 +113,45 @@ export function VoiceConfirmModal({ response, onClose }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="px-4 py-4 border-b border-ink space-y-2">
|
{/* Transcript */}
|
||||||
<Label>TRANSCRIPT</Label>
|
<section className="px-4 py-3 border-b border-ink flex-shrink-0">
|
||||||
<p className="font-sans text-sm leading-6 text-ink">
|
<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>}
|
{response.transcript || <span className="text-muted">Aucun audio reconnu.</span>}
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</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>
|
<Label>{hasActions ? "ACTIONS PROPOSÉES" : "AUCUNE ACTION"}</Label>
|
||||||
{hasActions ? (
|
{hasActions ? (
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2 mt-2">
|
||||||
{response.actions.map((a) => (
|
{response.actions.map((a) => {
|
||||||
<ActionRow
|
const effective = editedArgs.get(a.id) ?? a.action;
|
||||||
key={a.id}
|
const isEditing = editing === a.id;
|
||||||
action={a}
|
return (
|
||||||
selected={selected.has(a.id)}
|
<ActionRow
|
||||||
onToggle={() => toggle(a.id)}
|
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>
|
</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.
|
Mistral n'a pas trouvé d'action à proposer. Reformule ou ferme.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</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 && (
|
{confirm.isError && (
|
||||||
<span className="font-mono text-[10px] uppercase tracking-label text-accent">
|
<span className="font-mono text-[10px] uppercase tracking-label text-accent">
|
||||||
Échec — réessaie
|
Échec — réessaie
|
||||||
@ -137,7 +168,7 @@ export function VoiceConfirmModal({ response, onClose }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleConfirm}
|
onClick={() => void handleConfirm()}
|
||||||
disabled={!hasActions || selected.size === 0 || confirm.isPending}
|
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"
|
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({
|
function ActionRow({
|
||||||
action,
|
action,
|
||||||
selected,
|
selected,
|
||||||
|
isEditing,
|
||||||
|
isEdited,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
onToggleEdit,
|
||||||
|
onEdit,
|
||||||
}: {
|
}: {
|
||||||
action: ProposedActionWithId;
|
action: ProposedActionWithId;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
|
isEditing: boolean;
|
||||||
|
isEdited: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
|
onToggleEdit: () => void;
|
||||||
|
onEdit: (updated: ProposedAction) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const canEdit = action.action.fn === "create_calendar_event" || action.action.fn === "create_todo";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="border border-ink">
|
<li className="border border-ink">
|
||||||
<button
|
<div className="flex items-start gap-3 px-3 py-2">
|
||||||
type="button"
|
{/* Checkbox */}
|
||||||
onClick={onToggle}
|
<button
|
||||||
className="w-full flex items-start gap-3 px-3 py-2 text-left"
|
type="button"
|
||||||
>
|
onClick={onToggle}
|
||||||
<span
|
aria-pressed={selected}
|
||||||
className={cn(
|
className="mt-0.5 flex-shrink-0 w-4 h-4 border border-ink"
|
||||||
"mt-0.5 flex-shrink-0 w-4 h-4 border border-ink",
|
>
|
||||||
selected ? "bg-ink" : "bg-transparent",
|
<span className={cn("block w-full h-full", selected ? "bg-ink" : "bg-transparent")} />
|
||||||
)}
|
</button>
|
||||||
aria-hidden
|
|
||||||
/>
|
{/* Résumé */}
|
||||||
<div className="flex-1 min-w-0 space-y-1">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="font-mono text-[10px] uppercase tracking-label text-muted">
|
<div className="font-mono text-[10px] uppercase tracking-label text-muted">
|
||||||
{functionLabel(action.action.fn)}
|
{functionLabel(action.action.fn)}
|
||||||
|
{isEdited && (
|
||||||
|
<span className="ml-2 text-accent">· modifié</span>
|
||||||
|
)}
|
||||||
</div>
|
</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>
|
</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>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function functionLabel(fn: ProposedAction["fn"]): string {
|
function functionLabel(fn: ProposedAction["fn"]): string {
|
||||||
switch (fn) {
|
switch (fn) {
|
||||||
case "create_todo":
|
case "create_todo": return "Créer une tâche";
|
||||||
return "CRÉER UNE TÂCHE";
|
case "add_project_idea": return "Idée de projet";
|
||||||
case "add_project_idea":
|
case "add_project_step": return "Étape de projet";
|
||||||
return "IDÉE DE PROJET";
|
case "create_calendar_event": return "Événement agenda";
|
||||||
case "add_project_step":
|
case "toggle_daily_checkin": return "Check-in du jour";
|
||||||
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) {
|
switch (action.fn) {
|
||||||
case "create_todo": {
|
case "create_todo": {
|
||||||
const parts = [action.args.title];
|
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}`);
|
if (typeof action.args.priority === "number") parts.push(`· p${action.args.priority}`);
|
||||||
return parts.join(" ");
|
return parts.join(" ");
|
||||||
}
|
}
|
||||||
@ -212,22 +276,23 @@ function summarize(action: ProposedAction): string {
|
|||||||
case "add_project_step":
|
case "add_project_step":
|
||||||
return `${action.args.title}${action.args.status ? ` · ${action.args.status}` : ""}`;
|
return `${action.args.title}${action.args.status ? ` · ${action.args.status}` : ""}`;
|
||||||
case "create_calendar_event":
|
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":
|
case "toggle_daily_checkin":
|
||||||
return action.args.note ?? "Check-in du jour";
|
return action.args.note ?? "Check-in du jour";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
function formatDatetime(iso: string): string {
|
||||||
try {
|
try {
|
||||||
const d = new Date(iso);
|
return new Date(iso).toLocaleString("fr-FR", {
|
||||||
return d.toLocaleString("fr-FR", {
|
day: "2-digit", month: "short",
|
||||||
day: "2-digit",
|
hour: "2-digit", minute: "2-digit",
|
||||||
month: "short",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
});
|
||||||
} catch {
|
} catch { return iso; }
|
||||||
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
|
<Tag
|
||||||
className={cn(
|
className={cn(
|
||||||
"font-sans font-light tracking-tightest text-ink",
|
"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",
|
"[&_em]:not-italic [&_em]:text-accent [&_em]:italic",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as TodosRouteImport } from './routes/todos'
|
import { Route as TodosRouteImport } from './routes/todos'
|
||||||
import { Route as JobsRouteImport } from './routes/jobs'
|
import { Route as JobsRouteImport } from './routes/jobs'
|
||||||
|
import { Route as AgendaRouteImport } from './routes/agenda'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as SettingsJobsRouteImport } from './routes/settings.jobs'
|
import { Route as SettingsJobsRouteImport } from './routes/settings.jobs'
|
||||||
|
|
||||||
@ -24,6 +25,11 @@ const JobsRoute = JobsRouteImport.update({
|
|||||||
path: '/jobs',
|
path: '/jobs',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AgendaRoute = AgendaRouteImport.update({
|
||||||
|
id: '/agenda',
|
||||||
|
path: '/agenda',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const IndexRoute = IndexRouteImport.update({
|
const IndexRoute = IndexRouteImport.update({
|
||||||
id: '/',
|
id: '/',
|
||||||
path: '/',
|
path: '/',
|
||||||
@ -37,12 +43,14 @@ const SettingsJobsRoute = SettingsJobsRouteImport.update({
|
|||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/agenda': typeof AgendaRoute
|
||||||
'/jobs': typeof JobsRoute
|
'/jobs': typeof JobsRoute
|
||||||
'/todos': typeof TodosRoute
|
'/todos': typeof TodosRoute
|
||||||
'/settings/jobs': typeof SettingsJobsRoute
|
'/settings/jobs': typeof SettingsJobsRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/agenda': typeof AgendaRoute
|
||||||
'/jobs': typeof JobsRoute
|
'/jobs': typeof JobsRoute
|
||||||
'/todos': typeof TodosRoute
|
'/todos': typeof TodosRoute
|
||||||
'/settings/jobs': typeof SettingsJobsRoute
|
'/settings/jobs': typeof SettingsJobsRoute
|
||||||
@ -50,20 +58,22 @@ export interface FileRoutesByTo {
|
|||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/agenda': typeof AgendaRoute
|
||||||
'/jobs': typeof JobsRoute
|
'/jobs': typeof JobsRoute
|
||||||
'/todos': typeof TodosRoute
|
'/todos': typeof TodosRoute
|
||||||
'/settings/jobs': typeof SettingsJobsRoute
|
'/settings/jobs': typeof SettingsJobsRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/' | '/jobs' | '/todos' | '/settings/jobs'
|
fullPaths: '/' | '/agenda' | '/jobs' | '/todos' | '/settings/jobs'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/jobs' | '/todos' | '/settings/jobs'
|
to: '/' | '/agenda' | '/jobs' | '/todos' | '/settings/jobs'
|
||||||
id: '__root__' | '/' | '/jobs' | '/todos' | '/settings/jobs'
|
id: '__root__' | '/' | '/agenda' | '/jobs' | '/todos' | '/settings/jobs'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
|
AgendaRoute: typeof AgendaRoute
|
||||||
JobsRoute: typeof JobsRoute
|
JobsRoute: typeof JobsRoute
|
||||||
TodosRoute: typeof TodosRoute
|
TodosRoute: typeof TodosRoute
|
||||||
SettingsJobsRoute: typeof SettingsJobsRoute
|
SettingsJobsRoute: typeof SettingsJobsRoute
|
||||||
@ -85,6 +95,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof JobsRouteImport
|
preLoaderRoute: typeof JobsRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/agenda': {
|
||||||
|
id: '/agenda'
|
||||||
|
path: '/agenda'
|
||||||
|
fullPath: '/agenda'
|
||||||
|
preLoaderRoute: typeof AgendaRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/': {
|
'/': {
|
||||||
id: '/'
|
id: '/'
|
||||||
path: '/'
|
path: '/'
|
||||||
@ -104,6 +121,7 @@ declare module '@tanstack/react-router' {
|
|||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
|
AgendaRoute: AgendaRoute,
|
||||||
JobsRoute: JobsRoute,
|
JobsRoute: JobsRoute,
|
||||||
TodosRoute: TodosRoute,
|
TodosRoute: TodosRoute,
|
||||||
SettingsJobsRoute: SettingsJobsRoute,
|
SettingsJobsRoute: SettingsJobsRoute,
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { Outlet, createRootRoute, Link } from "@tanstack/react-router";
|
import { Outlet, createRootRoute, Link } from "@tanstack/react-router";
|
||||||
import { AccentDot, Label } from "@/design";
|
import { AccentDot, Label } from "@/design";
|
||||||
|
import { BottomNav } from "@/components/shell/BottomNav";
|
||||||
|
|
||||||
export const Route = createRootRoute({
|
export const Route = createRootRoute({
|
||||||
component: RootLayout,
|
component: RootLayout,
|
||||||
@ -14,22 +15,30 @@ function RootLayout() {
|
|||||||
<AccentDot />
|
<AccentDot />
|
||||||
<Label className="text-ink">ORDINARTHUR-OS</Label>
|
<Label className="text-ink">ORDINARTHUR-OS</Label>
|
||||||
</Link>
|
</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="/">Dashboard</NavLink>
|
||||||
<NavLink to="/jobs">Jobs</NavLink>
|
<NavLink to="/agenda">Agenda</NavLink>
|
||||||
<NavLink to="/todos">Todos</NavLink>
|
<NavLink to="/todos">Todos</NavLink>
|
||||||
|
<NavLink to="/jobs">Jobs</NavLink>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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 />
|
<Outlet />
|
||||||
</main>
|
</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">
|
<div className="mx-auto max-w-7xl flex items-center justify-between px-4 py-3">
|
||||||
<Label>v0.0.0 · phase 1</Label>
|
<Label>v0.0.0 · phase 1</Label>
|
||||||
<Label>arthurbarre.fr</Label>
|
<Label>arthurbarre.fr</Label>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<BottomNav />
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<div className="space-y-16">
|
<div className="space-y-8 md:space-y-16">
|
||||||
<section className="space-y-6">
|
<section className="space-y-6">
|
||||||
<Label prefix="[ 00 ]">ORDINARTHUR-OS</Label>
|
<Label prefix="[ 00 ]">ORDINARTHUR-OS</Label>
|
||||||
<BigHeading>
|
<BigHeading>
|
||||||
|
|||||||
@ -57,7 +57,7 @@ function TodosPage() {
|
|||||||
const done = data.filter((t) => t.status === "done");
|
const done = data.filter((t) => t.status === "done");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-10">
|
<div className="space-y-6 md:space-y-10">
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<Label prefix="[ 02 ]">PHASE 2 · TODOS</Label>
|
<Label prefix="[ 02 ]">PHASE 2 · TODOS</Label>
|
||||||
<BigHeading>
|
<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,
|
"when": 1745107200000,
|
||||||
"tag": "0004_daily_checkins",
|
"tag": "0004_daily_checkins",
|
||||||
"breakpoints": true
|
"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 "./todos";
|
||||||
export * from "./ai_actions";
|
export * from "./ai_actions";
|
||||||
export * from "./daily_checkins";
|
export * from "./daily_checkins";
|
||||||
|
export * from "./calendar_events";
|
||||||
|
|||||||
@ -220,7 +220,7 @@ export const ProposedAction = z.discriminatedUnion("fn", [
|
|||||||
args: z.object({
|
args: z.object({
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
description: z.string().optional(),
|
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(),
|
priority: z.number().int().min(0).max(3).optional(),
|
||||||
project_id: z.string().uuid().optional(),
|
project_id: z.string().uuid().optional(),
|
||||||
tags: z.array(z.string()).optional(),
|
tags: z.array(z.string()).optional(),
|
||||||
@ -245,8 +245,8 @@ export const ProposedAction = z.discriminatedUnion("fn", [
|
|||||||
fn: z.literal("create_calendar_event"),
|
fn: z.literal("create_calendar_event"),
|
||||||
args: z.object({
|
args: z.object({
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
starts_at: z.string().datetime(),
|
starts_at: z.string().datetime({ offset: true }),
|
||||||
ends_at: z.string().datetime(),
|
ends_at: z.string().datetime({ offset: true }),
|
||||||
location: z.string().optional(),
|
location: z.string().optional(),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
@ -307,6 +307,50 @@ export const AiConfirmResponse = z.object({
|
|||||||
});
|
});
|
||||||
export type AiConfirmResponse = z.infer<typeof AiConfirmResponse>;
|
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)
|
// Phase 7 — Daily check-in (médocs + note)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user