From 76237b2c751c9a5a4c71013444a99db4db9fa11c Mon Sep 17 00:00:00 2001 From: Viet Nguyen Date: Fri, 14 Aug 2026 06:19:10 +0000 Subject: [PATCH] feat: calendars that stay current, a daemon that stays running, and the email after the meeting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six things were listed as missing and are here. Each was built against the running artefact, and two of them only revealed their real bug that way. **Calendar subscriptions.** Adding a calendar meant typing the path of an `.ics` file, which a browser cannot produce and which is a snapshot — the agenda described whatever the calendar looked like on export day, and kept describing it. Now a URL: Google's secret iCal address, Apple's public calendar, an Outlook publish link. The daemon re-fetches every fifteen minutes and on startup, keeps the file it already has when a fetch fails, and shows the failure on the row, because a subscription that stopped working looks exactly like a week with no meetings. Deliberately not OAuth. That would mean a client secret inside an open-source binary anyone can read, plus an account-wide scope, so a notes app can learn what time the standup is. A URL grants one calendar and is revocable from the calendar's own settings. `file://`, `ftp://` and bare paths are refused: the daemon fetches this with the user's permissions, and "fetches whatever string it is handed" is how a local-first app becomes a way to read the disk. **"Your meeting is starting — take notes?"** A nudge in the five minutes before an event and the ten after, once per occurrence, never while recording, and only when `suggest_on_meeting` is on. Its button starts the recording instead of navigating, since walking the user to the record button wastes the minute the prompt exists to save. Nothing here ever records on its own; the therapy appointment in a work calendar is why. **A daemon that outlives the terminal.** `summo serve --background`, `summo status`, `summo stop`. Stopping is an HTTP request rather than a signal, so the daemon can refuse while a meeting is being recorded (`--force` overrides), and so it is the same code on Windows. `engine.json` stays the only record of a running daemon, and is removed on the way out rather than left for the next command to discover as stale. **The follow-up you have to send.** An email, a chat message, a recap for people who missed it, or the decisions and actions as a list — from the confirmed summary, not the raw transcript. Nothing is sent: it is editable text with copy, `mailto:` and keep-as-a- note. Running it end to end through a fake provider caught the prompt inheriting the summariser's ground rules, so the first draft was a polite customer email with `[t=03:12]` in the middle of it. **Notes that start in a shape.** Idea, decision, to-do, journal — blank first, since most notes are. Driving that in a browser found a data-loss bug that predates it: the notes editor showed only the text above the first `##`, and saving wrote that back *beside* the sections it had never displayed, duplicating them on every save. `note::as_text` and `note::split_sections` fix it, with a round-trip test. **Where a cloud would go**, as ADR 0007: a relay that stores bytes it cannot read, sync before sharing before teams, cloud models as a setting rather than a tier, and no account for the local app, ever. Also: the path-traversal test asserted a case no client can send — every URL parser collapses `..` before the request leaves — so it failed in any build with the interface bundled, which CI never builds. It now tests the encoded forms that do arrive intact. Verified: 1300+ Rust tests, 297 web tests, 17 browser suites including two new ones, and a real run — a calendar served over HTTP, subscribed, refreshed, broken, and unsubscribed; a background daemon started, queried and stopped; an email composed through a stub provider and saved as a note. --- README.md | 19 +- README.vi.md | 20 +- apps/web/e2e/calendar.mjs | 109 ++++++ apps/web/e2e/notes.mjs | 79 ++++ apps/web/package.json | 2 +- apps/web/src/components/agenda/Calendars.tsx | 249 +++++++++++++ apps/web/src/components/meeting/Compose.tsx | 185 ++++++++++ apps/web/src/components/shell/NudgeBar.tsx | 13 +- apps/web/src/i18n/en.json | 59 ++- apps/web/src/i18n/ja.json | 59 ++- apps/web/src/i18n/vi.json | 59 ++- apps/web/src/i18n/zh.json | 59 ++- apps/web/src/lib/compose.ts | 90 +++++ apps/web/src/lib/notes.ts | 59 +++ apps/web/src/lib/nudges.ts | 16 +- apps/web/src/screens/AgendaScreen.tsx | 79 +--- apps/web/src/screens/MeetingScreen.tsx | 6 + apps/web/src/screens/NotesScreen.tsx | 55 ++- crates/summo-cli/src/daemon.rs | 253 +++++++++++++ crates/summo-cli/src/main.rs | 103 +++++- crates/summo-engine/Cargo.toml | 3 + crates/summo-engine/src/calsync.rs | 368 +++++++++++++++++++ crates/summo-engine/src/compose.rs | 273 ++++++++++++++ crates/summo-engine/src/lib.rs | 2 + crates/summo-engine/src/nudge.rs | 140 +++++++ crates/summo-engine/src/server.rs | 258 ++++++++++++- crates/summo-llm/src/prompt.rs | 127 +++++++ crates/summo-vault/src/note.rs | 111 +++++- docs/adr/0007-cloud-without-a-cloud.md | 98 +++++ 29 files changed, 2815 insertions(+), 138 deletions(-) create mode 100644 apps/web/e2e/calendar.mjs create mode 100644 apps/web/e2e/notes.mjs create mode 100644 apps/web/src/components/agenda/Calendars.tsx create mode 100644 apps/web/src/components/meeting/Compose.tsx create mode 100644 apps/web/src/lib/compose.ts create mode 100644 crates/summo-cli/src/daemon.rs create mode 100644 crates/summo-engine/src/calsync.rs create mode 100644 crates/summo-engine/src/compose.rs create mode 100644 docs/adr/0007-cloud-without-a-cloud.md diff --git a/README.md b/README.md index 3b678ed..85ac677 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,8 @@ Usable end to end: record or import a recording, get a transcript with speakers agent-drafted summary you approve, tasks on a board, questions answered from the vault with citations, live translation of whatever is playing, dubbing, notes, calendars, comments, a roster of agents you edit as files, and encrypted sync between machines through any folder you both can reach. +Subscribe to a calendar by URL and it stays current, prompts before a meeting starts — it never +records on its own — and drafts the follow-up email afterwards, which you send yourself. **Not done:** mobile is scaffolded and has never been compiled, and the hosted sync relay is not built — sync works today through any shared folder instead. @@ -39,13 +41,13 @@ The numbers below are measured on this codebase, not estimated. Each is reproduc command shown; see [`docs/benchmarks.md`](docs/benchmarks.md) and [`docs/translation.md`](docs/translation.md) for the full method and caveats. -| Claim | Measured | Source | -|---|---|---| -| Vietnamese recognition accuracy | 8.5 % WER, 6.7 % CER (`gipformer-65M`, 100 FLEURS VI clips, 21.3 min; 5.3 % on the 84 clips whose reference contains no digits) | `cargo run --release -p summo-bench --features asr -- asr` | -| Live pipeline speed | RTF 0.107, roughly 9× faster than realtime (raw mic capture) | `docs/benchmarks.md`, end-to-end pipeline section — two short single-mic captures, not yet WER-scored | -| Voice activity detection | Silero v5, F1 0.940 (precision 0.925, recall 0.956) | `cargo run --release -p summo-bench --features silero -- vad --sweep` | -| Finding a meeting without an index | ~30 ms across 1,000 meetings (8-thread scan), which is why there is no database | `cargo run --release -p summo-bench -- vault --sizes 100,1000,5000` | -| Translating a line | ~244 ms/line, 8 threads, with the default 583 MB `small100` model — in the released binary, with no model server to run | `cargo run -p summo-mt --features local,onnx --example compare` | +| Claim | Measured | Source | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| Vietnamese recognition accuracy | 8.5 % WER, 6.7 % CER (`gipformer-65M`, 100 FLEURS VI clips, 21.3 min; 5.3 % on the 84 clips whose reference contains no digits) | `cargo run --release -p summo-bench --features asr -- asr` | +| Live pipeline speed | RTF 0.107, roughly 9× faster than realtime (raw mic capture) | `docs/benchmarks.md`, end-to-end pipeline section — two short single-mic captures, not yet WER-scored | +| Voice activity detection | Silero v5, F1 0.940 (precision 0.925, recall 0.956) | `cargo run --release -p summo-bench --features silero -- vad --sweep` | +| Finding a meeting without an index | ~30 ms across 1,000 meetings (8-thread scan), which is why there is no database | `cargo run --release -p summo-bench -- vault --sizes 100,1000,5000` | +| Translating a line | ~244 ms/line, 8 threads, with the default 583 MB `small100` model — in the released binary, with no model server to run | `cargo run -p summo-mt --features local,onnx --example compare` | ## Install and run @@ -76,6 +78,7 @@ real-time factor, licence — and you can disagree with it. Nothing is recorded ```bash summo serve --port 8710 # a fixed port, when something else wants to find it summo serve --no-open # a server, when there is no browser to open +summo serve --background # run detached; `summo status` and `summo stop` from anywhere summo import ~/Downloads/zoom-recording.mp4 summo mcp # the vault over MCP, for Claude Code or Cursor ``` @@ -105,7 +108,7 @@ Summo is never the distributor of a licence it cannot redistribute under. Disconnect the machine from the network, then run `./summo serve` and record a meeting. Recognition, voice-activity detection and speaker attribution keep working, because they never called out — there -is no cloud-ASR fallback to fail over to. What you should *not* be able to do offline is get a summary +is no cloud-ASR fallback to fail over to. What you should _not_ be able to do offline is get a summary or a translation from a remote model you configured, since that is the one deliberate exception to "nothing leaves the machine". diff --git a/README.vi.md b/README.vi.md index dd051b5..c48ca24 100644 --- a/README.vi.md +++ b/README.vi.md @@ -32,7 +32,8 @@ cơ sở dữ liệu của ai khác. Ba nguyên tắc sau đây theo từ đó, một bản tóm tắt do agent soạn để bạn duyệt, việc cần làm trên bảng kanban, hỏi đáp trả lời từ kho dữ liệu kèm trích dẫn, dịch trực tiếp nội dung đang phát, lồng tiếng (dubbing), ghi chú, lịch, bình luận, một dàn agent bạn chỉnh sửa như file, và đồng bộ mã hoá giữa các máy qua bất kỳ thư mục dùng -chung nào. +chung nào. Đăng ký lịch bằng URL thì lịch luôn cập nhật, sắp tới giờ họp app hỏi có ghi chú không — +không bao giờ tự ghi âm — và họp xong thì soạn sẵn thư gửi đi để bạn tự gửi. **Chưa xong:** phần mobile mới ở dạng khung sườn và chưa từng biên dịch được, và relay đồng bộ trên cloud (hosted sync relay) chưa được xây — hiện tại đồng bộ vẫn chạy qua một thư mục dùng chung. @@ -41,13 +42,13 @@ Các con số dưới đây đều đo trên chính codebase này, không phải bằng đúng lệnh ghi kèm; xem đầy đủ phương pháp và các lưu ý ở [`docs/benchmarks.md`](docs/benchmarks.md) và [`docs/translation.md`](docs/translation.md). -| Nhận định | Đo được | Nguồn | -|---|---|---| -| Độ chính xác nhận dạng tiếng Việt | 8,5 % WER, 6,7 % CER (`gipformer-65M`, 100 clip FLEURS VI, 21,3 phút; còn 5,3 % nếu bỏ các clip mà bản tham chiếu viết số bằng chữ số) | `cargo run --release -p summo-bench --features asr -- asr` | -| Tốc độ pipeline chạy live | RTF 0,107, tức nhanh hơn thời gian thực khoảng 9 lần (ghi bằng mic thô) | `docs/benchmarks.md`, mục pipeline đầu-cuối — mới đo trên hai đoạn ghi ngắn từ một mic, chưa tính WER | -| Voice activity detection (VAD) | Silero v5, F1 0,940 (precision 0,925, recall 0,956) | `cargo run --release -p summo-bench --features silero -- vad --sweep` | -| Tìm một cuộc họp mà không cần index | ~30 ms trên 1.000 cuộc họp (scan 8 luồng) — đây cũng là lý do không có database | `cargo run --release -p summo-bench -- vault --sizes 100,1000,5000` | -| Dịch một dòng | ~244 ms/dòng, 8 luồng, với model mặc định `small100` nặng 583 MB — chạy ngay trong binary phát hành, không cần dựng model server | `cargo run -p summo-mt --features local,onnx --example compare` | +| Nhận định | Đo được | Nguồn | +| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| Độ chính xác nhận dạng tiếng Việt | 8,5 % WER, 6,7 % CER (`gipformer-65M`, 100 clip FLEURS VI, 21,3 phút; còn 5,3 % nếu bỏ các clip mà bản tham chiếu viết số bằng chữ số) | `cargo run --release -p summo-bench --features asr -- asr` | +| Tốc độ pipeline chạy live | RTF 0,107, tức nhanh hơn thời gian thực khoảng 9 lần (ghi bằng mic thô) | `docs/benchmarks.md`, mục pipeline đầu-cuối — mới đo trên hai đoạn ghi ngắn từ một mic, chưa tính WER | +| Voice activity detection (VAD) | Silero v5, F1 0,940 (precision 0,925, recall 0,956) | `cargo run --release -p summo-bench --features silero -- vad --sweep` | +| Tìm một cuộc họp mà không cần index | ~30 ms trên 1.000 cuộc họp (scan 8 luồng) — đây cũng là lý do không có database | `cargo run --release -p summo-bench -- vault --sizes 100,1000,5000` | +| Dịch một dòng | ~244 ms/dòng, 8 luồng, với model mặc định `small100` nặng 583 MB — chạy ngay trong binary phát hành, không cần dựng model server | `cargo run -p summo-mt --features local,onnx --example compare` | ## Cài đặt và chạy @@ -78,6 +79,7 @@ gì được ghi lại cho tới khi bạn bấm nút ghi. ```bash summo serve --port 8710 # cố định một port, khi có thứ khác cần tìm tới nó +summo serve --background # chạy nền; `summo status` để xem, `summo stop` để dừng summo serve --no-open # chạy server, khi không có trình duyệt nào để mở summo import ~/Downloads/zoom-recording.mp4 summo mcp # đưa kho dữ liệu ra qua MCP, cho Claude Code hay Cursor @@ -109,7 +111,7 @@ không có quyền phân phối lại. Ngắt kết nối mạng của máy, rồi chạy `./summo serve` và ghi một cuộc họp. Nhận dạng giọng nói, VAD và tách người nói vẫn chạy bình thường, vì chúng chưa bao giờ gọi ra ngoài — không có đường lùi nào sang -ASR trên cloud để mà thất bại. Điều bạn *sẽ không* làm được khi offline là lấy một bản tóm tắt hay +ASR trên cloud để mà thất bại. Điều bạn _sẽ không_ làm được khi offline là lấy một bản tóm tắt hay bản dịch từ model từ xa mà bạn đã cấu hình — đó là ngoại lệ duy nhất, có chủ đích, của lời hứa "không gì rời khỏi máy". diff --git a/apps/web/e2e/calendar.mjs b/apps/web/e2e/calendar.mjs new file mode 100644 index 0000000..ffc42ff --- /dev/null +++ b/apps/web/e2e/calendar.mjs @@ -0,0 +1,109 @@ +/** + * Subscribing to a calendar, in the browser, against a real calendar server. + * + * The unit tests cover which URLs are refused and the daemon test covers the fetch. What neither + * can cover is the part that was actually missing for a year: the only way to add a calendar was to + * type the *path of a file*, which a browser cannot produce and which stops being true the day + * after it is exported. This drives the form a person uses. + * + * The calendar is served by a two-line HTTP server in this process, so the suite depends on no + * network and no account. That is also what makes it able to assert the failure path — the server + * is told to answer 404 and the row has to say so, because a subscription that quietly stopped + * working looks exactly like a week with no meetings. + */ +import { createServer } from "node:http"; +import { chromium } from "playwright"; + +import { daemon } from "./daemon.mjs"; + +const problems = []; + +// ---- a calendar server ---------------------------------------------------- +const stamp = (epoch) => new Date(epoch * 1000).toISOString().replace(/[-:]|\.\d{3}/g, ""); +const soon = Math.floor(Date.now() / 1000) + 3600; +const ICS = + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n" + + `BEGIN:VEVENT\r\nUID:e2e-1\r\nSUMMARY:Họp chốt giá\r\nDTSTART:${stamp(soon)}\r\n` + + `DTEND:${stamp(soon + 1800)}\r\n` + + "ATTENDEE:mailto:ngoc@acme.vn\r\nATTENDEE:mailto:binh@acme.vn\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; + +let serving = true; +const calendars = createServer((request, response) => { + if (!serving) { + response.writeHead(404).end("gone"); + return; + } + response.writeHead(200, { "content-type": "text/calendar" }).end(ICS); +}); +await new Promise((resolve) => calendars.listen(0, "127.0.0.1", resolve)); +const address = `http://127.0.0.1:${calendars.address().port}/work.ics`; + +const engine = await daemon(process.argv, { name: "calendar" }); +const browser = await chromium.launch(); +const context = await browser.newContext({ + locale: "vi-VN", + viewport: { width: 1280, height: 950 }, +}); +const page = await context.newPage(); +page.on("pageerror", (e) => problems.push(`pageerror: ${e.message}`)); + +await page.goto(`${engine.url}?port=${engine.port}&token=${engine.token}#/agenda`, { + waitUntil: "networkidle", +}); + +// ---- subscribing ---------------------------------------------------------- +{ + await page.getByLabel("Địa chỉ lịch (URL)").fill(address); + await page.getByLabel("Tên lịch").fill("Lịch công ty"); + await page.getByRole("button", { name: "Đăng ký", exact: true }).click(); + + const row = page.getByTestId("calendar-list").getByText("Lịch công ty"); + await row.waitFor({ timeout: 15000 }).catch(() => problems.push("the calendar was never listed")); + + // The meeting from that calendar, on the agenda, without a reload. + await page + .getByText("Họp chốt giá") + .first() + .waitFor({ timeout: 15000 }) + .catch(() => problems.push("the subscribed calendar's meeting never reached the agenda")); + + const state = await page.getByTestId("calendar-list").innerText(); + if (!/1 sự kiện/.test(state)) problems.push(`the row does not say what it holds: "${state}"`); +} + +// ---- a subscription that stops working ------------------------------------ +{ + serving = false; + await page.getByRole("button", { name: /Đồng bộ lại/ }).click(); + + const failed = page.getByText(/không tìm thấy lịch/); + await failed + .waitFor({ timeout: 15000 }) + .catch(() => problems.push("a broken subscription reports nothing")); + + // And the meetings it already fetched are still there: a laptop that woke up without WiFi should + // still show this morning's meetings. + if ((await page.getByText("Họp chốt giá").count()) === 0) { + problems.push("a failed refresh threw away the calendar it already had"); + } +} + +// ---- a URL that is not a calendar ----------------------------------------- +{ + await page.getByLabel("Địa chỉ lịch (URL)").fill("file:///etc/passwd"); + await page.getByRole("button", { name: "Đăng ký", exact: true }).click(); + await page + .getByText(/phải bắt đầu bằng https/) + .waitFor({ timeout: 10000 }) + .catch(() => problems.push("a file:// URL was not refused in the interface")); +} + +await browser.close(); +await engine.stop(); +calendars.close(); + +if (problems.length > 0) { + console.error(problems.map((p) => ` - ${p}`).join("\n")); + process.exit(1); +} +console.log("calendar ok"); diff --git a/apps/web/e2e/notes.mjs b/apps/web/e2e/notes.mjs new file mode 100644 index 0000000..5352c6e --- /dev/null +++ b/apps/web/e2e/notes.mjs @@ -0,0 +1,79 @@ +/** + * Starting a note that is already the right shape. + * + * A blank page is the right default and a poor only option: people were typing the same four sets + * of headings by hand — an idea, a decision, a list of things to do, a day's journal — and a note + * app that watched them do it and offered nothing is the one they stop opening. + * + * What this asserts is the part that would break silently: that choosing a kind puts its headings + * *in the file*, rather than into some hidden template state that a later edit or an export would + * lose. + */ +import { chromium } from "playwright"; + +import { daemon } from "./daemon.mjs"; + +const problems = []; +const engine = await daemon(process.argv, { name: "notes" }); +const browser = await chromium.launch(); +const context = await browser.newContext({ + locale: "vi-VN", + viewport: { width: 1280, height: 950 }, +}); +const page = await context.newPage(); +page.on("pageerror", (e) => problems.push(`pageerror: ${e.message}`)); + +await page.goto(`${engine.url}?port=${engine.port}&token=${engine.token}#/notes`, { + waitUntil: "networkidle", +}); + +// ---- the kinds on offer --------------------------------------------------- +await page.getByRole("button", { name: "Mới", exact: true }).click(); +const menu = page.getByTestId("note-kinds"); +await menu.waitFor({ timeout: 10000 }).catch(() => problems.push("no kinds were offered")); + +const offered = (await menu.innerText()).split("\n").filter(Boolean); +if (offered.length < 5) + problems.push(`only ${offered.length} kinds offered: ${offered.join(", ")}`); +if (!/Trống/.test(offered[0] ?? "")) { + problems.push(`a blank note should be first, got "${offered[0]}"`); +} + +// ---- a decision note starts with a decision's headings -------------------- +await menu.getByRole("button", { name: "Quyết định" }).click(); + +const body = page.getByLabel("Nội dung ghi chú"); +await body.waitFor({ timeout: 10000 }); +// The seed has to be in the editor, which is what proves it is in the file rather than a label. +await page + .waitForFunction( + () => document.querySelector("textarea")?.value.includes("## Bối cảnh") ?? false, + { timeout: 10000 }, + ) + .catch(() => problems.push("the decision note did not start with a decision's headings")); + +// ---- and it survives a reload, because it was saved ----------------------- +{ + const before = await body.inputValue(); + // Typing is what triggers the save; a note created and never touched is allowed to be empty. + await body.click(); + await body.press("End"); + await body.type(" Ngọc chốt."); + await page.waitForTimeout(3000); + await page.reload({ waitUntil: "networkidle" }); + + const notes = await page.getByText("Quyết định").count(); + if (notes === 0) problems.push("the note is not in the list after a reload"); + if (!before.includes("## Quyết định")) { + problems.push(`the seed is missing its own heading: ${JSON.stringify(before.slice(0, 40))}`); + } +} + +await browser.close(); +await engine.stop(); + +if (problems.length > 0) { + console.error(problems.map((p) => ` - ${p}`).join("\n")); + process.exit(1); +} +console.log("notes ok"); diff --git a/apps/web/package.json b/apps/web/package.json index c2c94d7..942dc53 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,7 +9,7 @@ "preview": "vite preview", "test": "vitest run", "tauri": "tauri", - "e2e": "node e2e/shell.mjs && node e2e/library.mjs && node e2e/meeting.mjs && node e2e/tasks.mjs && node e2e/draft.mjs && node e2e/nudges.mjs && node e2e/chat.mjs && node e2e/languages.mjs && node e2e/overlap.mjs && node e2e/models.mjs && node e2e/search.mjs && node e2e/assistant.mjs && node e2e/language.mjs && node e2e/permissions.mjs && node e2e/density.mjs", + "e2e": "node e2e/shell.mjs && node e2e/library.mjs && node e2e/meeting.mjs && node e2e/tasks.mjs && node e2e/draft.mjs && node e2e/nudges.mjs && node e2e/chat.mjs && node e2e/languages.mjs && node e2e/overlap.mjs && node e2e/models.mjs && node e2e/search.mjs && node e2e/assistant.mjs && node e2e/language.mjs && node e2e/permissions.mjs && node e2e/calendar.mjs && node e2e/notes.mjs && node e2e/density.mjs", "lint": "eslint . --max-warnings 0", "format": "prettier --write .", "format:check": "prettier --check .", diff --git a/apps/web/src/components/agenda/Calendars.tsx b/apps/web/src/components/agenda/Calendars.tsx new file mode 100644 index 0000000..f8f5fb3 --- /dev/null +++ b/apps/web/src/components/agenda/Calendars.tsx @@ -0,0 +1,249 @@ +import { RefreshCw } from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; + +import { useI18n } from "../../i18n/context"; +import { useEngine } from "../../lib/engine-context"; +import { useErrorText } from "../../lib/errors"; +import { pickFile } from "../../lib/imports"; +import { AgendaClient, type Calendars as CalendarList } from "../../lib/notes"; +import { useLoad } from "../../lib/use-load"; +import { Button, Input, Labelled } from "../ui"; + +/** + * The calendars this app reads, and where they come from. + * + * Adding one used to mean typing the path of a `.ics` file, which is a snapshot: the agenda + * describes whatever the calendar looked like on the day it was exported, and quietly keeps + * describing it. Everything people actually use publishes a URL instead — Google calls it the + * *secret address in iCal format*, Apple calls it a *public calendar* — and a URL the daemon can + * fetch is a calendar that stays right. + * + * No account is connected. Signing in with Google would mean shipping a client secret inside an + * open-source binary and asking for an account-wide scope so a notes app can learn when the standup + * is; a link the user chooses to paste grants one calendar and is revocable from the calendar's own + * settings. The instructions for finding it are on this screen, because that is the only hard part. + * + * Adding a file still works, and such a calendar is listed as what it is: something that will not + * refresh. + */ +export function CalendarSources({ onChange }: { onChange: () => void }) { + const { handshake } = useEngine(); + const { t, locale } = useI18n(); + const say = useErrorText(); + // Memoised because it is a dependency of the load below, and a new client every render would + // re-fetch the calendar list on every keystroke in the address field. + const client = useMemo(() => new AgendaClient(handshake), [handshake]); + + const [title, setTitle] = useState(""); + const [address, setAddress] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const list = useLoad( + useCallback(async () => client.calendars(), [client]), + [client], + ); + + // Both, always: the list of calendars and the agenda drawn from them are two views of the same + // fetch, and refreshing one without the other is how a calendar appears with no meetings in it. + const reload = () => { + list.reload(); + onChange(); + }; + + const subscribe = async () => { + if (!address.trim()) return; + setBusy(true); + setError(null); + try { + // The name is optional: a calendar with no name is far likelier than one nobody can identify, + // and the host is a better guess than an empty row. + await client.subscribe(title.trim() || hostOf(address), address.trim()); + setTitle(""); + setAddress(""); + reload(); + } catch (e) { + setError(say(e)); + } finally { + setBusy(false); + } + }; + + const addFile = async () => { + const chosen = await pickFile("iCalendar"); + if (chosen === null) { + setError(t("import.no_dialog")); + return; + } + if (!chosen.trim()) return; + setError(null); + try { + const fallback = (chosen.split(/[/\\]/).pop() ?? "calendar").replace(/\.ics$/i, ""); + await client.addCalendar(chosen, title.trim() || fallback); + setTitle(""); + reload(); + } catch (e) { + setError(say(e)); + } + }; + + const refresh = async (name?: string) => { + setBusy(true); + setError(null); + try { + await client.refreshCalendars(name); + reload(); + } catch (e) { + setError(say(e)); + } finally { + setBusy(false); + } + }; + + const remove = async (name: string) => { + setError(null); + try { + await client.removeCalendar(name); + reload(); + } catch (e) { + setError(say(e)); + } + }; + + const calendars: CalendarList = list.data ?? { subscriptions: [], files: [] }; + + return ( +
+
+ + setAddress(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void subscribe(); + }} + placeholder={t("agenda.subscribe_placeholder")} + // Never `type="url"`: a browser refuses to submit `webcal://…` as a URL, which is + // precisely the address Apple and Outlook hand out. + inputMode="url" + spellCheck={false} + /> + + + setTitle(e.target.value)} + placeholder={t("agenda.name_placeholder")} + /> + + + +
+ +
+ {t("agenda.how")} +
    +
  • {t("agenda.how_google")}
  • +
  • {t("agenda.how_apple")}
  • +
  • {t("agenda.how_outlook")}
  • +
+
+ + {error && ( +

+ {error} +

+ )} + + {(calendars.subscriptions.length > 0 || calendars.files.length > 0) && ( +
    + {calendars.subscriptions.map((subscription) => ( +
  • + + {subscription.title} + + {subscription.last_error ?? + [ + t("agenda.events", { count: subscription.events }), + subscription.last_sync === null + ? t("agenda.never_synced") + : t("agenda.synced", { when: when(subscription.last_sync, locale) }), + ].join(" · ")} + + + + +
  • + ))} + + {calendars.files.map((file) => ( +
  • + + {file.name} + {/* Said plainly. A file calendar that stopped matching reality is otherwise + indistinguishable from a subscription that is working. */} + + {[t("agenda.events", { count: file.events }), t("agenda.from_file")].join(" · ")} + + + +
  • + ))} +
+ )} +
+ ); +} + +/** A host to name a calendar after, when the user did not name it. */ +function hostOf(address: string): string { + const withoutScheme = address.trim().replace(/^[a-z]+:\/\//i, ""); + return withoutScheme.split("/")[0] || "calendar"; +} + +/** "3 phút trước", from a timestamp, in whatever language the interface is in. */ +function when(epoch: number, locale: string): string { + const seconds = Math.round(epoch - Date.now() / 1000); + const format = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }); + const minutes = Math.round(seconds / 60); + if (Math.abs(minutes) < 60) return format.format(minutes, "minute"); + const hours = Math.round(minutes / 60); + if (Math.abs(hours) < 24) return format.format(hours, "hour"); + return format.format(Math.round(hours / 24), "day"); +} diff --git a/apps/web/src/components/meeting/Compose.tsx b/apps/web/src/components/meeting/Compose.tsx new file mode 100644 index 0000000..06ec4d9 --- /dev/null +++ b/apps/web/src/components/meeting/Compose.tsx @@ -0,0 +1,185 @@ +import { Mail } from "lucide-react"; +import { useState } from "react"; + +import { useI18n } from "../../i18n/context"; +import { + ComposeClient, + copy, + mailto, + type Composed, + type Kind, + type Tone, +} from "../../lib/compose"; +import { useEngine } from "../../lib/engine-context"; +import { useErrorText } from "../../lib/errors"; +import { Button, Input, Labelled, SegmentedControl, TextArea } from "../ui"; + +const KINDS: Kind[] = ["email", "message", "recap", "actions"]; +const TONES: Tone[] = ["neutral", "friendly", "formal"]; + +/** + * Write the follow-up, out of the meeting that is already on screen. + * + * Four shapes rather than a prompt box, because the shape is the part a model gets wrong: an email + * needs a subject and a sign-off, a chat message must fit in a glance, a recap cannot say "as + * discussed" to people who were not there, and a list of actions is a list. + * + * Everything it produces is editable before it goes anywhere, and it goes nowhere by itself: the + * buttons are copy, open in your own mail app, and keep as a note. That is deliberate. A model + * writing a customer email will occasionally invent a deadline — the prompt makes it mark gaps with + * `[…]` instead, but the real defence is that a person reads it and presses send themselves. + */ +export function ComposePanel({ meeting, title }: { meeting: string; title: string }) { + const { handshake } = useEngine(); + const { t } = useI18n(); + const say = useErrorText(); + const client = new ComposeClient(handshake); + + const [open, setOpen] = useState(false); + const [kind, setKind] = useState("email"); + const [tone, setTone] = useState("neutral"); + const [audience, setAudience] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [draft, setDraft] = useState(null); + const [subject, setSubject] = useState(""); + const [body, setBody] = useState(""); + const [note, setNote] = useState(null); + const [copied, setCopied] = useState(false); + + const run = async () => { + setBusy(true); + setError(null); + setNote(null); + try { + const composed = await client.compose(meeting, { + kind, + tone, + audience: audience.trim() || undefined, + }); + setDraft(composed); + setSubject(composed.subject ?? ""); + setBody(composed.body); + } catch (e) { + setError(say(e)); + } finally { + setBusy(false); + } + }; + + const keep = async () => { + setError(null); + try { + setNote(await client.save(meeting, subject.trim() || title, body)); + } catch (e) { + setError(say(e)); + } + }; + + const wholeMessage = subject.trim() ? `${subject}\n\n${body}` : body; + + return ( +
+
+
+ + {open && ( + <> +

{t("compose.hint")}

+ +
+ ({ value, label: t(`compose.kind_${value}`) }))} + label={t("compose.kind")} + /> + ({ value, label: t(`compose.tone_${value}`) }))} + label={t("compose.tone")} + /> +
+ +
+ + setAudience(e.target.value)} + placeholder={t("compose.audience_placeholder")} + /> + + +
+ + {error && ( +

+ {error} +

+ )} + + {draft && ( +
+ {draft.kind === "email" && ( + + setSubject(e.target.value)} /> + + )} + +