diff --git a/kits/weekly-routine-coach/README.md b/kits/weekly-routine-coach/README.md new file mode 100644 index 00000000..2460869d --- /dev/null +++ b/kits/weekly-routine-coach/README.md @@ -0,0 +1,106 @@ +# Weekly Routine Coach + +A Lamatic AgentKit **kit** that turns a free-text brain-dump of goals and commitments into a balanced weekly routine on a 30-minute grid — and replans when something slips. Bilingual: detects PT-BR or EN from the first message and stays in that language. + +> **Why this kit?** Of the ~70 existing AgentKit contributions, none addresses personal time-blocking. Career copilots, summarizers, content generators and RAG bots exist in abundance — but no one had built a coach that turns intentions into a real weekly grid and reshuffles when life intervenes. This is the only "personal productivity" kit in AgentKit. + +## What it does + +1. **Intake (conversational)** — type your week freely. The agent extracts categories, fixed commitments, recurring goals, one-off events and preferences. Asks one clarifying question at a time until it has enough. +2. **Generate week** — places everything on a 7-day, 30-min grid. Respects sleep (≥7h/day), meals/breaks (≥1.5h/day), fixed commitments (never overwritten), and per-goal time-window preferences. Distributes recurring goals across non-consecutive days when possible. If a goal's target hours can't fit, it surfaces the gap honestly in `unmet_goals` rather than silently shrinking blocks. +3. **Replan** — click a goal block to mark it as skipped; the agent reshuffles within the week and shows a diff (which blocks moved, which were dropped). The model is told to minimize churn. + +All three flows share one constitution (`constitutions/default.md`) that makes the realism rules non-negotiable across the kit. + +## How it's built + +- **3 Lamatic flows** (`intake`, `generate-week`, `replan`), each a Generate JSON node behind an API Request trigger. +- **Model**: Gemini 2.5 Flash (free tier of Google AI Studio). +- **App**: Next.js 16 + React 19 + Tailwind v4 + shadcn/ui. Single-page state machine (chat → loading → grid → slip dialog). +- **Client**: bare `fetch` against Lamatic's GraphQL endpoint with a thin `unwrap()` helper that undoes two platform quirks (see *Notes on platform quirks* below). + +## Prerequisites + +| Tool | Version | +|---|---| +| Node.js | 18+ | +| npm | 9+ | +| A free [Google AI Studio API key](https://aistudio.google.com/app/apikey) | for Gemini | +| A free [Lamatic account](https://lamatic.ai) | to host the flows | + +## Setup + +### 1. Deploy the three flows on Lamatic + +This kit's three flows must be deployed to your own Lamatic project before the app can call them. The flow files in `flows/` are Lamatic Studio exports — to deploy: + +1. Sign in to [Lamatic Studio](https://studio.lamatic.ai) and create a project. +2. Add a Gemini credential (Settings → Integrations → Google Gemini → paste your AI Studio key). +3. Create three flows named exactly `intake`, `generate-week`, `replan`. Match each flow to the corresponding `flows/.ts` (Trigger input schema, Generate JSON system & user prompts, output Zod schema, and API Response `outputMapping`). The system & user prompts live in `prompts/`. +4. Deploy each flow and copy its Flow ID. + +### 2. Configure and run the app + +```bash +cd apps +cp .env.example .env.local +# Fill in LAMATIC_API_URL, LAMATIC_PROJECT_ID, LAMATIC_API_KEY, +# INTAKE_FLOW_ID, GENERATE_WEEK_FLOW_ID, REPLAN_FLOW_ID +npm install --legacy-peer-deps +npm run dev +``` + +Open [http://localhost:3000](http://localhost:3000) and start chatting. + +> `--legacy-peer-deps` is needed because `vaul` (used by shadcn's Drawer) declares React 16–18 as a peer and we run on React 19. The behavior at runtime is identical. + +### 3. Deploy on Vercel (optional) + +Click the deploy button in `lamatic.config.ts` (or click "Deploy" on the kit page in the AgentKit catalog). Vercel will prompt for the six environment variables. + +## Project structure + +``` +kits/weekly-routine-coach/ +├── lamatic.config.ts # kit metadata + flow → envKey mapping +├── agent.md # agent identity + capability doc +├── README.md # this file +├── flows/ +│ ├── intake.ts # exported from Studio +│ ├── generate-week.ts +│ └── replan.ts +├── prompts/ # externalized prompts referenced by flows +│ ├── intake_*.md +│ ├── generate-week_*.md +│ └── replan_*.md +├── constitutions/ +│ └── default.md # inviolable rules shared by all flows +└── apps/ + ├── app/ # Next.js App Router + ├── components/ui/ # shadcn/ui primitives + ├── actions/orchestrate.ts # typed server actions for the 3 flows + ├── lib/lamatic-client.ts # GraphQL client + unwrap() helper + └── .env.example +``` + +## Notes on platform quirks + +The app's `lib/lamatic-client.ts` includes an `unwrap()` helper that undoes two artifacts of Lamatic's `outputMapping` system: + +1. **Leading `$` on every value.** The template syntax `${{NodeId.output.field}}` substitutes the inner expression but leaves `$` as a literal character. `unwrap()` strips it. +2. **Objects and arrays serialized as JSON strings.** Lamatic's GraphQL Response schema declares fields as `str`/`int`/`float`/`obj`/`arr`, but `outputMapping` only reliably interpolates string values — so we serialize objects with `${{...}}` inside string quotes on the way out, then `JSON.parse()` on the way in. `unwrap()` does this for any string starting with `{` or `[`. + +There's no `bool` type in the response schema, so booleans round-trip as `"true"` / `"false"` strings. `unwrap()` converts these too. + +These behaviors are documented in `agent.md` under *Common Failure Modes* so future contributors aren't surprised. + +## Roadmap (not in MVP) + +- Friday review flow — compare planned vs. actually-completed blocks and surface aderência patterns. +- Google Calendar two-way sync — pull fixed commitments, push generated blocks. +- Email or Slack daily nudges. +- A "stretch" variant for habit stacking (read 30min, plus reading-streak counter). + +## Credits + +Built by **Igor Michalski** for the Lamatic.ai AgentKit Challenge (May 2026). Inspired by [Crono](https://github.com/igormichalski) — a desktop weekly-routine planner — but reimagined as an agent that does the placement work itself. diff --git a/kits/weekly-routine-coach/agent.md b/kits/weekly-routine-coach/agent.md new file mode 100644 index 00000000..0840f1c2 --- /dev/null +++ b/kits/weekly-routine-coach/agent.md @@ -0,0 +1,108 @@ +# Weekly Routine Coach — Agent Identity + +## Overview + +Weekly Routine Coach turns a free-text brain-dump of goals and commitments into a balanced weekly routine on a 30-minute grid. It conducts a short conversation to extract structured information, generates a populated 7-day schedule, and re-plans when something slips. The agent is bilingual (PT-BR / EN), follows the user's language consistently within a session, and operates under a non-negotiable constitution that enforces realism (sleep, meals, fixed-commitment integrity). + +## Purpose + +People who try to plan their week usually fail because: + +1. They brain-dump tasks but don't actually **place** them on a time grid. +2. Life is recurring (gym, study) + one-off (meeting, errand) and most tools force one or the other. +3. When something slips on Tuesday, no tool replans the rest of the week. +4. At week's end, there's no honest comparison of planned vs. done. + +This agent addresses all four. It is a **coach**, not a calendar — it places intentions in time and adapts when reality intervenes. + +## Flows + +### 1. `intake` (conversational) + +- **Trigger**: API Request. Input: `message`, `today`, `session_state` (accumulating state, may be `{}` on first call). +- **Processing**: a Generate JSON node (Gemini 2.5 Flash) reads the message + state, extracts structured information (categories, fixed commitments, recurring goals, one-off events, preferences), and decides whether to ask a clarifying question or confirm. +- **Response**: `{ language, assistant_message, is_complete, session_state, missing_info }`. +- **When to use**: every user turn during onboarding. The app calls intake repeatedly, passing the latest `session_state` back, until `is_complete: true`. +- **Output**: full updated `session_state` (never drops prior data) + next assistant message. +- **Dependencies**: `prompts/intake_system.md`, `prompts/intake_user.md`, `constitutions/default.md`. + +### 2. `generate-week` (placement) + +- **Trigger**: API Request. Input: the full session state + a Monday date. +- **Processing**: Generate JSON node with a longer placement-focused system prompt. The agent positions sleep first, then fixed commitments, then meals/breaks, then recurring goals into remaining slots, respecting preferences and per-goal time windows. +- **Response**: `{ week_start_date, blocks, unmet_goals, summary }`. `blocks` is the full 7-day grid; `unmet_goals` honestly declares any target hours the model couldn't fit. +- **When to use**: once the user confirms the captured state and clicks "Generate". +- **Latency note**: this is the heaviest call — ~60–90s on Gemini 2.5 Flash. The app uses a 180s timeout. +- **Dependencies**: `prompts/generate-week_system.md`, `prompts/generate-week_user.md`, `constitutions/default.md`. + +### 3. `replan` (reactive) + +- **Trigger**: API Request. Input: current blocks + a single `change` event (`slip`, `new_event`, or `completed`). +- **Processing**: Generate JSON node reads the change and reshuffles only goal-blocks (never touches fixed, sleep, or meal blocks). Returns the new state plus a diff (added / removed / moved) explaining exactly what changed. +- **Response**: `{ updated_blocks, diff, unmet_goals, summary }`. +- **When to use**: user marks a block as skipped, or wants to add a one-off event. +- **Dependencies**: `prompts/replan_system.md`, `prompts/replan_user.md`, `constitutions/default.md`. + +## Guardrails + +The constitution (`constitutions/default.md`) is inviolable and enforced via the system prompt of every flow: + +- **Sleep**: every day has ≥7 hours of `kind: "sleep"`. +- **Meals & breaks**: ≥1.5 hours/day combined. +- **Fixed commitments**: never overwritten. Goals yield to fixed blocks, not the other way around. +- **Active hours**: ≤14 per day. +- **Granularity**: 30-minute blocks. Never off-grid (no `18:15`). +- **Honesty**: when a goal's target cannot fit, the agent declares the gap in `unmet_goals` with a one-sentence reason — it does **not** silently shrink blocks or drop goals. +- **Replan churn minimization**: the model is instructed to move as few blocks as possible. +- **Out of scope**: the agent does not do task management, project breakdowns, or give medical/legal/financial advice. + +## Integration Reference + +- **LLM provider**: Google Gemini via Lamatic's connection. All three flows use **gemini-2.5-flash** (free tier of Google AI Studio; ~15 req/min limit). +- **Lamatic Studio**: project hosts the three flows behind a single GraphQL endpoint. Each flow has its own workflow ID (see `lamatic.config.ts` step `envKey`s). +- **No external services**: no calendar sync, no database, no third-party APIs beyond Lamatic + Gemini. + +## Environment Setup + +The Next.js app in `apps/` requires these environment variables (see `apps/.env.example`): + +| Variable | Source | +|---|---| +| `LAMATIC_API_URL` | Studio → Settings → API Keys → "Connect to your project" dialog → API URL | +| `LAMATIC_PROJECT_ID` | Same dialog → Project ID | +| `LAMATIC_API_KEY` | Studio → Settings → API Keys → existing or new key | +| `INTAKE_FLOW_ID` | Studio → `intake` flow → Details → Flow ID | +| `GENERATE_WEEK_FLOW_ID` | Studio → `generate-week` flow → Details → Flow ID | +| `REPLAN_FLOW_ID` | Studio → `replan` flow → Details → Flow ID | + +## Quickstart + +1. Clone the repo and `cd kits/weekly-routine-coach/apps`. +2. `cp .env.example .env.local` and fill in the values above. +3. `npm install --legacy-peer-deps` (vaul depends on React 16-18 peer; we use React 19). +4. `npm run dev` → open `http://localhost:3000`. +5. In the chat, type something like `"Trabalho seg-sex 9-18, queria treinar 4x na semana e estudar inglês 1h por dia"` and press Enter. +6. Continue answering the agent's clarifying questions until it offers to generate the week. +7. Click **"Gerar minha semana"** (or "Generate my week") and wait ~1 min. +8. Click any `goal` block on the grid to mark it as skipped — the agent will replan. + +## Common Failure Modes + +| Symptom | Cause | Fix | +|---|---|---| +| `Lamatic credentials missing` on startup | `.env.local` not loaded | Make sure file is at `apps/.env.local`, not `apps/.env`. Restart `npm run dev`. | +| All output fields prefixed with `$` (e.g. `$pt-BR`) in raw API response | Quirk of Lamatic's `${{...}}` template syntax leaving `$` literal. | Handled by `unwrap()` in `apps/lib/lamatic-client.ts`. If a new field is added, route it through `unwrap()` too. | +| `session_state` returned as a string instead of object | Lamatic's outputMapping serializes obj/arr as JSON strings. | `unwrap()` JSON-parses any string starting with `{` or `[`. | +| `generate-week` times out at 180s | Placement is hard for smaller models. | Increase `timeoutMs` in `executeFlow()` or simplify input (fewer goals/categories). | +| `day` field returned as `"Monday"` instead of `"mon"` | Gemini occasionally ignores the 3-letter convention in the prompt. | App's `normalizeDay()` normalizes any case ending with the 3-letter prefix. | +| `is_complete` arrives as `"true"`/`"false"` (string) | Lamatic's response schema doesn't support `bool` — only `str`. | `unwrap()` converts `"true"`/`"false"` strings to booleans. | +| Build error `Module not found: '@/actions/orchestrate'` | `tsconfig.json` missing `paths` mapping. | Already configured. If you regenerate the tsconfig, re-add `"paths": { "@/*": ["./*"] }`. | + +## Files of Note + +- `flows/intake.ts`, `flows/generate-week.ts`, `flows/replan.ts` — exported flow definitions from Lamatic Studio. +- `prompts/*.md` — system & user prompts for each flow, externalized via `@references`. +- `constitutions/default.md` — the inviolable rules every flow inherits. +- `apps/actions/orchestrate.ts` — server actions wrapping the three flows with typed inputs/outputs and result-parsing helpers. +- `apps/lib/lamatic-client.ts` — minimal fetch-based GraphQL client + `unwrap()` quirk-undoer. +- `apps/app/page.tsx` — the single-page UI: chat → grid → slip dialog state machine. diff --git a/kits/weekly-routine-coach/apps/.env.example b/kits/weekly-routine-coach/apps/.env.example new file mode 100644 index 00000000..e0170588 --- /dev/null +++ b/kits/weekly-routine-coach/apps/.env.example @@ -0,0 +1,9 @@ +# Lamatic API credentials (Settings → API Keys) +LAMATIC_API_URL="https://your-org-your-project.lamatic.dev" +LAMATIC_PROJECT_ID="your-project-id" +LAMATIC_API_KEY="lt-your-api-key" + +# Flow IDs (Studio → each flow → Details → Flow ID) +INTAKE_FLOW_ID="your-intake-flow-id" +GENERATE_WEEK_FLOW_ID="your-generate-week-flow-id" +REPLAN_FLOW_ID="your-replan-flow-id" diff --git a/kits/weekly-routine-coach/apps/.gitignore b/kits/weekly-routine-coach/apps/.gitignore new file mode 100644 index 00000000..3c35968e --- /dev/null +++ b/kits/weekly-routine-coach/apps/.gitignore @@ -0,0 +1,28 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules + +# next.js +/.next/ +/out/ + +# production +/build + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files +.env* +!.env.example + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts \ No newline at end of file diff --git a/kits/weekly-routine-coach/apps/README.md b/kits/weekly-routine-coach/apps/README.md new file mode 100644 index 00000000..ba7b64b4 --- /dev/null +++ b/kits/weekly-routine-coach/apps/README.md @@ -0,0 +1,39 @@ +# Weekly Routine Coach — App + +Next.js 16 + React 19 + Tailwind v4 + shadcn/ui front-end for the Weekly Routine Coach kit. + +See the [kit README](../README.md) and [`agent.md`](../agent.md) at the kit root for the full picture (problem statement, flow design, platform quirks). + +## Run locally + +```bash +cp .env.example .env.local +# fill in the 6 env vars (see ../README.md → Setup) +npm install --legacy-peer-deps +npm run dev +``` + +Open http://localhost:3000. + +## Architecture + +- `app/page.tsx` — single-page state machine: `intake` (chat) → `generating` (loader) → `week` (grid) → `replanning` (slip dialog). +- `actions/orchestrate.ts` — three typed server actions (`callIntake`, `callGenerateWeek`, `callReplan`) wrapping the Lamatic flows with input serialization and output `unwrap()`. +- `lib/lamatic-client.ts` — minimal GraphQL `fetch` client that calls Lamatic's `executeWorkflow` mutation. Includes the `unwrap()` helper that strips the `$` prefix Lamatic leaves on substituted values and `JSON.parse()`-es obj/arr fields that round-trip as strings. + +## Environment variables + +| Var | Source | +|---|---| +| `LAMATIC_API_URL` | Studio → Settings → API Keys → Connect dialog → API URL | +| `LAMATIC_PROJECT_ID` | Same dialog → Project ID | +| `LAMATIC_API_KEY` | Studio → Settings → API Keys | +| `INTAKE_FLOW_ID` | Studio → `intake` flow → Details → Flow ID | +| `GENERATE_WEEK_FLOW_ID` | Studio → `generate-week` flow → Details → Flow ID | +| `REPLAN_FLOW_ID` | Studio → `replan` flow → Details → Flow ID | + +## Notes + +- `--legacy-peer-deps` is needed because `vaul` (shadcn's Drawer dependency) declares React 16–18 as peer; we run on React 19. Runtime behavior is identical. +- `tsconfig.json` must include `"paths": { "@/*": ["./*"] }` for the `@/components/...` imports. Already configured. +- `postcss.config.mjs` is required for Tailwind v4 (which uses `@tailwindcss/postcss` instead of the legacy `tailwindcss` plugin). diff --git a/kits/weekly-routine-coach/apps/actions/orchestrate.ts b/kits/weekly-routine-coach/apps/actions/orchestrate.ts new file mode 100644 index 00000000..6c706f06 --- /dev/null +++ b/kits/weekly-routine-coach/apps/actions/orchestrate.ts @@ -0,0 +1,212 @@ +"use server"; + +import { executeFlow, unwrap } from "@/lib/lamatic-client"; + +const INTAKE_FLOW_ID = process.env.INTAKE_FLOW_ID!; +const GENERATE_WEEK_FLOW_ID = process.env.GENERATE_WEEK_FLOW_ID!; +const REPLAN_FLOW_ID = process.env.REPLAN_FLOW_ID!; + +type Lang = "pt-BR" | "en"; +type DayOfWeek = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun"; + +export type Category = { + id: string; + name: string; + color: string; + weekly_target_hours?: number; +}; + +export type FixedCommitment = { + id: string; + day: DayOfWeek; + start: string; + end: string; + label: string; + category_id?: string; +}; + +export type RecurringGoal = { + id: string; + label: string; + target_hours_per_week: number; + preferred_days?: DayOfWeek[]; + preferred_time_window?: { start: string; end: string }; + avoid_time_window?: { start: string; end: string }; + min_block_minutes?: number; + max_block_minutes?: number; + category_id?: string; +}; + +export type OneOffEvent = { + id: string; + date: string; + start: string; + end: string; + label: string; + category_id?: string; +}; + +export type Preferences = { + earliest_wake?: string; + latest_sleep?: string; + lunch_window?: { start: string; end: string }; + no_activity_before?: string; + no_activity_after?: string; + deep_work_when?: "morning" | "afternoon" | "evening"; +}; + +export type Block = { + id: string; + day: DayOfWeek; + start: string; + end: string; + kind: "fixed" | "goal" | "oneoff" | "sleep" | "meal" | "break"; + label: string; + category_id?: string; + source_goal_id?: string; +}; + +export type UnmetGoal = { + goal_id: string; + goal_label: string; + target_hours: number; + scheduled_hours: number; + gap_hours: number; + reason: string; +}; + +export type SessionState = { + language: Lang; + categories: Category[]; + fixed_commitments: FixedCommitment[]; + recurring_goals: RecurringGoal[]; + oneoff_events: OneOffEvent[]; + preferences: Preferences; +}; + +export type IntakeResult = { + language: Lang; + assistant_message: string; + is_complete: boolean; + session_state: SessionState; + missing_info: string[]; +}; + +export type GenerateWeekResult = { + week_start_date: string; + blocks: Block[]; + unmet_goals: UnmetGoal[]; + summary: string; +}; + +export type ReplanChange = + | { kind: "slip"; block_id: string; reason?: string } + | { kind: "new_event"; event: OneOffEvent } + | { kind: "completed"; block_id: string }; + +export type ReplanResult = { + updated_blocks: Block[]; + diff: { + added: Block[]; + removed: { block_id: string; reason: string }[]; + moved: { + block_id: string; + from: { day: DayOfWeek; start: string }; + to: { day: DayOfWeek; start: string }; + }[]; + }; + unmet_goals: UnmetGoal[]; + summary: string; +}; + +const normalizeDay = (raw: string): DayOfWeek => { + const lower = raw.toLowerCase(); + if (lower.length >= 3 && ["mon", "tue", "wed", "thu", "fri", "sat", "sun"].includes(lower.slice(0, 3))) { + return lower.slice(0, 3) as DayOfWeek; + } + return lower as DayOfWeek; +}; + +const normalizeBlocks = (blocks: Block[]): Block[] => + blocks.map((b) => ({ ...b, day: normalizeDay(b.day) })); + +const normalizeSessionState = (raw: SessionState): SessionState => ({ + ...raw, + fixed_commitments: raw.fixed_commitments.map((c) => ({ ...c, day: normalizeDay(c.day) })), +}); + +export async function callIntake(input: { + message: string; + today: string; + session_state?: SessionState | Record; +}): Promise { + const raw = await executeFlow>(INTAKE_FLOW_ID, { + message: input.message, + today: input.today, + session_state: JSON.stringify(input.session_state ?? {}), + }); + + const result: IntakeResult = { + language: unwrap(raw.language) as Lang, + assistant_message: unwrap(raw.assistant_message) as string, + is_complete: unwrap(raw.is_complete) as boolean, + session_state: unwrap(raw.session_state) as SessionState, + missing_info: unwrap(raw.missing_info) as string[], + }; + result.session_state = normalizeSessionState(result.session_state); + return result; +} + +export async function callGenerateWeek(input: { + week_start_date: string; + language: Lang; + categories: Category[]; + fixed_commitments: FixedCommitment[]; + recurring_goals: RecurringGoal[]; + oneoff_events: OneOffEvent[]; + preferences: Preferences; +}): Promise { + const raw = await executeFlow>(GENERATE_WEEK_FLOW_ID, { + week_start_date: input.week_start_date, + language: input.language, + categories: JSON.stringify(input.categories), + fixed_commitments: JSON.stringify(input.fixed_commitments), + recurring_goals: JSON.stringify(input.recurring_goals), + oneoff_events: JSON.stringify(input.oneoff_events), + preferences: JSON.stringify(input.preferences), + }); + + return { + week_start_date: unwrap(raw.week_start_date) as string, + blocks: normalizeBlocks(unwrap(raw.blocks) as Block[]), + unmet_goals: unwrap(raw.unmet_goals) as UnmetGoal[], + summary: unwrap(raw.summary) as string, + }; +} + +export async function callReplan(input: { + current_blocks: Block[]; + language: Lang; + categories: Category[]; + recurring_goals: RecurringGoal[]; + fixed_commitments: FixedCommitment[]; + preferences: Preferences; + change: ReplanChange; +}): Promise { + const raw = await executeFlow>(REPLAN_FLOW_ID, { + current_blocks: JSON.stringify(input.current_blocks), + language: input.language, + categories: JSON.stringify(input.categories), + recurring_goals: JSON.stringify(input.recurring_goals), + fixed_commitments: JSON.stringify(input.fixed_commitments), + preferences: JSON.stringify(input.preferences), + change: JSON.stringify(input.change), + }); + + return { + updated_blocks: normalizeBlocks(unwrap(raw.updated_blocks) as Block[]), + diff: unwrap(raw.diff) as ReplanResult["diff"], + unmet_goals: unwrap(raw.unmet_goals) as UnmetGoal[], + summary: unwrap(raw.summary) as string, + }; +} diff --git a/kits/weekly-routine-coach/apps/app/globals.css b/kits/weekly-routine-coach/apps/app/globals.css new file mode 100644 index 00000000..dc2aea17 --- /dev/null +++ b/kits/weekly-routine-coach/apps/app/globals.css @@ -0,0 +1,125 @@ +@import 'tailwindcss'; +@import 'tw-animate-css'; + +@custom-variant dark (&:is(.dark *)); + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.145 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.145 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.985 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.396 0.141 25.723); + --destructive-foreground: oklch(0.637 0.237 25.331); + --border: oklch(0.269 0 0); + --input: oklch(0.269 0 0); + --ring: oklch(0.439 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(0.269 0 0); + --sidebar-ring: oklch(0.439 0 0); +} + +@theme inline { + --font-sans: 'Geist', 'Geist Fallback'; + --font-mono: 'Geist Mono', 'Geist Mono Fallback'; + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/kits/weekly-routine-coach/apps/app/layout.tsx b/kits/weekly-routine-coach/apps/app/layout.tsx new file mode 100644 index 00000000..627a43a9 --- /dev/null +++ b/kits/weekly-routine-coach/apps/app/layout.tsx @@ -0,0 +1,45 @@ +import type { Metadata } from 'next' +import { Geist, Geist_Mono } from 'next/font/google' +import { Analytics } from '@vercel/analytics/next' +import './globals.css' + +const _geist = Geist({ subsets: ["latin"] }); +const _geistMono = Geist_Mono({ subsets: ["latin"] }); + +export const metadata: Metadata = { + title: 'Weekly Routine Coach', + description: 'Turns goals into a balanced weekly routine. Bilingual PT-BR / EN.', + generator: 'Lamatic AgentKit', + icons: { + icon: [ + { + url: '/icon-light-32x32.png', + media: '(prefers-color-scheme: light)', + }, + { + url: '/icon-dark-32x32.png', + media: '(prefers-color-scheme: dark)', + }, + { + url: '/icon.svg', + type: 'image/svg+xml', + }, + ], + apple: '/apple-icon.png', + }, +} + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( + + + {children} + + + + ) +} diff --git a/kits/weekly-routine-coach/apps/app/page.tsx b/kits/weekly-routine-coach/apps/app/page.tsx new file mode 100644 index 00000000..a313a4ff --- /dev/null +++ b/kits/weekly-routine-coach/apps/app/page.tsx @@ -0,0 +1,589 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Card } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { Loader2, Send, Calendar, RotateCcw } from "lucide-react"; +import { + callIntake, + callGenerateWeek, + callReplan, + type IntakeResult, + type SessionState, + type Block, + type UnmetGoal, + type Category, +} from "@/actions/orchestrate"; + +type ChatMessage = { role: "user" | "assistant"; content: string }; +type Stage = "intake" | "generating" | "week" | "replanning"; + +const DAYS: { id: "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun"; label: { "pt-BR": string; en: string } }[] = [ + { id: "mon", label: { "pt-BR": "Seg", en: "Mon" } }, + { id: "tue", label: { "pt-BR": "Ter", en: "Tue" } }, + { id: "wed", label: { "pt-BR": "Qua", en: "Wed" } }, + { id: "thu", label: { "pt-BR": "Qui", en: "Thu" } }, + { id: "fri", label: { "pt-BR": "Sex", en: "Fri" } }, + { id: "sat", label: { "pt-BR": "Sáb", en: "Sat" } }, + { id: "sun", label: { "pt-BR": "Dom", en: "Sun" } }, +]; + +const HOUR_START = 6; +const HOUR_END = 24; +const HOUR_PX = 44; + +const t = (lang: "pt-BR" | "en") => ({ + title: lang === "pt-BR" ? "Weekly Routine Coach" : "Weekly Routine Coach", + intakePlaceholder: + lang === "pt-BR" + ? "Conte sua semana: trabalho, metas, preferências..." + : "Tell me about your week: work, goals, preferences...", + send: lang === "pt-BR" ? "Enviar" : "Send", + generateWeek: lang === "pt-BR" ? "Gerar minha semana" : "Generate my week", + generating: lang === "pt-BR" ? "Montando sua semana..." : "Building your week...", + generatingHint: + lang === "pt-BR" + ? "Isso leva ~1 min. O agente está posicionando seus blocos respeitando sono, refeições e preferências." + : "Takes ~1 min. The agent is placing your blocks while respecting sleep, meals, and preferences.", + weekSummary: lang === "pt-BR" ? "Sua semana" : "Your week", + unmetGoals: lang === "pt-BR" ? "Metas não atendidas" : "Unmet goals", + restart: lang === "pt-BR" ? "Recomeçar" : "Restart", + slipDialogTitle: lang === "pt-BR" ? "Não fez esse bloco?" : "Skipped this block?", + slipDialogDesc: + lang === "pt-BR" + ? "O agente vai tentar reencaixar em outro horário da semana." + : "The agent will try to reschedule it elsewhere in the week.", + cancel: lang === "pt-BR" ? "Cancelar" : "Cancel", + confirmSlip: lang === "pt-BR" ? "Sim, replanejar" : "Yes, replan", + replanning: lang === "pt-BR" ? "Replanejando..." : "Replanning...", +}); + +export default function Home() { + const [stage, setStage] = useState("intake"); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [sessionState, setSessionState] = useState(null); + const [isComplete, setIsComplete] = useState(false); + const [blocks, setBlocks] = useState([]); + const [unmetGoals, setUnmetGoals] = useState([]); + const [summary, setSummary] = useState(""); + const [error, setError] = useState(null); + const [pending, setPending] = useState(false); + const [slipBlock, setSlipBlock] = useState(null); + const chatEndRef = useRef(null); + + const lang = sessionState?.language ?? "pt-BR"; + const i18n = t(lang); + + useEffect(() => { + chatEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + async function sendMessage() { + if (!input.trim() || pending) return; + const userMsg = input.trim(); + setInput(""); + setMessages((m) => [...m, { role: "user", content: userMsg }]); + setPending(true); + setError(null); + try { + const today = new Date().toISOString().slice(0, 10); + const res = await callIntake({ + message: userMsg, + today, + session_state: sessionState ?? {}, + }); + setSessionState(res.session_state); + setIsComplete(res.is_complete); + setMessages((m) => [...m, { role: "assistant", content: res.assistant_message }]); + } catch (e) { + setError(e instanceof Error ? e.message : "Unknown error"); + } finally { + setPending(false); + } + } + + async function generateWeek() { + if (!sessionState) return; + setStage("generating"); + setError(null); + try { + const today = new Date(); + const day = today.getDay(); + const diff = (day === 0 ? -6 : 1) - day; + const monday = new Date(today); + monday.setDate(today.getDate() + diff); + const weekStart = monday.toISOString().slice(0, 10); + + const res = await callGenerateWeek({ + week_start_date: weekStart, + language: sessionState.language, + categories: sessionState.categories, + fixed_commitments: sessionState.fixed_commitments, + recurring_goals: sessionState.recurring_goals, + oneoff_events: sessionState.oneoff_events, + preferences: sessionState.preferences, + }); + setBlocks(res.blocks); + setUnmetGoals(res.unmet_goals); + setSummary(res.summary); + setStage("week"); + } catch (e) { + setError(e instanceof Error ? e.message : "Unknown error"); + setStage("intake"); + } + } + + async function handleSlip(block: Block) { + if (!sessionState) return; + setStage("replanning"); + setError(null); + try { + const res = await callReplan({ + current_blocks: blocks, + language: sessionState.language, + categories: sessionState.categories, + recurring_goals: sessionState.recurring_goals, + fixed_commitments: sessionState.fixed_commitments, + preferences: sessionState.preferences, + change: { kind: "slip", block_id: block.id }, + }); + setBlocks(res.updated_blocks); + setUnmetGoals(res.unmet_goals); + setSummary(res.summary); + setStage("week"); + setSlipBlock(null); + } catch (e) { + setError(e instanceof Error ? e.message : "Unknown error"); + setStage("week"); + } + } + + function restart() { + setStage("intake"); + setMessages([]); + setSessionState(null); + setIsComplete(false); + setBlocks([]); + setUnmetGoals([]); + setSummary(""); + setError(null); + } + + return ( +
+
+
+

+ + {i18n.title} +

+ {stage === "week" && ( + + )} +
+
+ +
+ {error && ( + +

+ {error} +

+
+ )} + + {(stage === "intake" || stage === "generating") && ( + + )} + + {(stage === "week" || stage === "replanning") && ( + setSlipBlock(b)} + replanning={stage === "replanning"} + i18n={i18n} + lang={lang} + /> + )} +
+ + !open && setSlipBlock(null)}> + + + {i18n.slipDialogTitle} + + {slipBlock && ( + + {slipBlock.label} · {slipBlock.day} {slipBlock.start}–{slipBlock.end} + + )} +
+ {i18n.slipDialogDesc} +
+
+ + + + +
+
+
+ ); +} + +function IntakePanel({ + messages, + input, + setInput, + sendMessage, + pending, + isComplete, + sessionState, + generateWeek, + chatEndRef, + i18n, + generating, +}: { + messages: ChatMessage[]; + input: string; + setInput: (v: string) => void; + sendMessage: () => void; + pending: boolean; + isComplete: boolean; + sessionState: SessionState | null; + generateWeek: () => void; + chatEndRef: React.RefObject; + i18n: ReturnType; + generating: boolean; +}) { + if (generating) { + return ( + + +

{i18n.generating}

+

+ {i18n.generatingHint} +

+
+ ); + } + + return ( +
+ +
+ {messages.length === 0 && ( +
+

+ Try: +
+ + Trabalho seg-sex 9-18, queria treinar 4x na semana e estudar inglês 1h por dia + +

+
+ )} + {messages.map((m, i) => ( +
+
+ {m.content} +
+
+ ))} + {pending && ( +
+
+ +
+
+ )} +
+
+
+