add agenda

This commit is contained in:
ordinarthur 2026-04-16 14:20:00 +02:00
parent 242abdba5d
commit 7de7ef16b9
25 changed files with 1551 additions and 88 deletions

View File

@ -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("*");
}

View File

@ -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(),

View 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);
}
}

View 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 {}

View 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");
}

View File

@ -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,

View File

@ -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 };
}
}
}
}

View File

@ -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: {

View 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",
});
}

View 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);
}

View 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)}`;
}

View 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
}

View File

@ -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; }
}

View 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>
);
}

View File

@ -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,
)}

View File

@ -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,

View File

@ -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>
);
}

View 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);
}

View File

@ -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>

View File

@ -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>

View 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
);

View File

@ -36,6 +36,13 @@
"when": 1745107200000,
"tag": "0004_daily_checkins",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1745193600000,
"tag": "0005_agenda",
"breakpoints": true
}
]
}

View 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;

View File

@ -3,3 +3,4 @@ export * from "./jobs";
export * from "./todos";
export * from "./ai_actions";
export * from "./daily_checkins";
export * from "./calendar_events";

View File

@ -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)
// ---------------------------------------------------------------------------