diff --git a/.dockerignore b/.dockerignore index 85f733a76..a14e01086 100644 --- a/.dockerignore +++ b/.dockerignore @@ -18,6 +18,9 @@ apps/site/.twoslash apps/mobile/.expo apps/mobile/ios apps/mobile/android +apps/expo-kloq-example/.expo +apps/expo-kloq-example/ios +apps/expo-kloq-example/android out # The generated SDKs (sdks/*): no image needs them. sdks diff --git a/AGENTS.md b/AGENTS.md index 4f8b0c29a..b5ed55436 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ webhooks, notification emails and contacts, optionally after the submitter confi `AGPL-3.0-only` (root `LICENSE`), except `packages/sdk` (MIT, its own `LICENSE`: customers bundle it), `packages/mcp` and `packages/cli` (MIT, published with the SDK), the Claude Code plugin and Agent Skill in `plugins/coss` (MIT, its own `LICENSE`: installed into customers' agents), the framework drivers in `integrations/*` (MIT, each with its own -`LICENSE`), the generated SDKs in `sdks/*` (MIT, each with its own `LICENSE`), `packages/kloq` (MIT, vendored) and +`LICENSE`), the generated SDKs in `sdks/*` (MIT, each with its own `LICENSE`), `packages/kloq` (MIT, vendored), `packages/expo-kloq` (MIT, its own `LICENSE`: kloq's port) and the fonts (OFL). New code is AGPL unless it lives in one of those; never copy AGPL code into the SDK, the MCP server, the CLI, the plugin, the drivers or the generated SDKs. Outside contributors sign the CLA (`CLA.md`, enforced by cla-assistant.io on pull requests; the agreement text it shows is a gist that must match `CLA.md`: change both together, as a new version). @@ -41,6 +41,7 @@ text it shows is a gist that must match `CLA.md`: change both together, as a new | `apps/sign` | `@coss/sign-app` | Sign's signing page (:3004) on its own domain (`cosssign.com`, CloudFront → ALB, `SIGN_HOST` in `infra/aws/routing.ts`): one signer, signed in by a session the customer's backend creates (token in the URL fragment, never a cookie), embedded with `coss/sign`'s `mountSigning` or opened on its own; consent, the documents (pdf.js), fields, a typed or drawn signature, decline; and the embedded template builder (`/b/`). See `apps/sign/AGENTS.md`. | | `apps/scheduling` | `@coss/scheduling-app` | The hosted scheduling pages (:3005) on a domain of their own (production: `cossbook.com`, CloudFront → ALB, `SCHEDULING_HOST` in `infra/aws/routing.ts`): a host's page, the booker and the manage page, each signed in by a session (token in the URL fragment, never a cookie), embedded with `coss/scheduling`. A thin client of the API's `/scheduling/client/*`. See `apps/scheduling/AGENTS.md`. | | `apps/mobile` | `@coss/mobile` | Expo (SDK 57) iOS companion app, three native tabs (Home, Products, Organization), mirroring the dashboard's pages (organization: projects, reputation, domains with DMARC, dedicated IPs, SMS senders, members, spend and budgets, settings with SSO and SCIM, billing (read-only), audit log; environment: emails, templates, received, suppressions, tracking, content storage, SMS, broadcasts, contacts, audiences, segments, topics, marketing settings, risk with its rules, lists and settings, Hooks, Scheduling, API keys, webhooks, logs, log drains), mostly read-only, signed in through the dashboard (browser hand-off + PKCE) and using its `/api/mobile/v1/*`, with push notifications (APNs) for alerts, crash reporting (Sentry) and account deletion. Not deployed by `infra`. See `apps/mobile/AGENTS.md`. | +| `apps/expo-kloq-example` | `@coss/expo-kloq-example` | Expo (SDK 57) development build showing `expo-kloq` with seeded calendars; its `plugins/with-scene-delegate.js` adopts the scene life cycle iOS 27 requires, and `maestro/demo.yaml` walks every interaction. Not deployed. See its `README.md`. | | `apps/smtp` | `@coss/smtp` | Bun SMTP relay (`smtp.coss.com`; locally :2465 implicit TLS, :2587 STARTTLS) on `smtp-server`: username `coss`, an API key as the password; each message becomes a `POST /v1/emails` request (`@coss/email/smtp`) stored through the same email service, rate limits and quotas. Behind an NLB; terminates TLS itself (exported ACM certificate). See `apps/smtp/AGENTS.md`. | | `apps/worker` | `@coss/worker` | Bun background worker: email dispatch (pg-boss), scheduled sends, broadcast fan-out, webhook deliveries, domain re-checks, dedicated IP pools, stale-claim/unclaimed sweeps, data retention purges, organization deletions, SES events and received emails from SQS (`EMAIL_PROVIDER=ses`). `scripts/ses-bootstrap.ts`. Safe to run several. | | `packages/db` | `@coss/db` | Drizzle schema (generated Better Auth tables + domain tables), pg client, TypeIDs, migrations, test guard; `./jobs` (pg-boss); `./idempotency` (`withIdempotencyKey`, request hashes); `./partitions` (daily range partitions: create ahead, drop expired). | @@ -77,7 +78,8 @@ text it shows is a gist that must match `CLA.md`: change both together, as a new | `packages/mcp` | `coss-mcp` | **Public** MCP server (`npx coss-mcp`, stdio, Node; hosted at `api.coss.com/mcp` by `apps/api` through `coss-mcp/http`): tools for coding agents built only on the published `coss` SDK: docs search, per-product setup (`get_setup`) and diagnosis (`diagnose_*`), sends, suppressions, domains, webhooks, received mail, logs, log drains, marketing, sub-accounts, Verify, Sign, Forms, Scheduling, Hooks, Risk, account status, a read-only fallback for any `/v1/` path (`api_get`), plus prompts and resources; `COSS_MCP_TOOLSETS` offers fewer. See `packages/mcp/AGENTS.md`. | | `plugins/coss` | — | **Public** Claude Code plugin (marketplace: the root `.claude-plugin/marketplace.json`; `/plugin install coss --marketplace cosscom/coss`) with the `coss` Agent Skill (`skills/coss/SKILL.md`, also installed into other agents by `npx skills add cosscom/coss`) and the MCP server (`.mcp.json`, `npx -y coss-mcp`). Plain files, not a workspace; served from main. See "Coding agents" below. | | `packages/cli` | `coss-cli` | **Public** CLI (`npm i -g coss-cli`, command `coss`, Node): `login`, `init`, `emails`, `suppressions`, `subaccounts`, `sms`, `verify`, `contacts`, `audiences`, `segments`, `topics`, `broadcasts`, `marketing settings`, `sign`, `forms`, `scheduling`, `hooks`, `risk`, `domains`, `templates`, `webhooks`, `logs tail`, `log-drains`, `listen`, `api`, built only on the published `coss` SDK. See `packages/cli/AGENTS.md`. | -| `packages/kloq` | `kloq` | The kloq library (the `cosscom/calendar` repository's `packages/kloq/src`, tests left out): the `` calendar, its themes, settings dialog, design mode and stylesheet. Vendored (excluded from Biome, `noUncheckedIndexedAccess` off); also what `/kloq/r/kloq.json` serves as source. Used only by `apps/site`. | +| `packages/kloq` | `kloq` | The kloq library (the `cosscom/calendar` repository's `packages/kloq/src`, tests left out): the `` calendar, its themes, settings dialog, design mode and stylesheet. Vendored (excluded from Biome, `noUncheckedIndexedAccess` off); also what `/kloq/r/kloq.json` serves as source. Used by `apps/site`, and by `packages/expo-kloq` through `kloq/lib/*` (the model modules only). | +| `packages/expo-kloq` | `expo-kloq` | kloq for Expo / React Native: `` (day, 3-day, week with a multi-zone gutter, month, year, schedule, timeline; the event sheet with recurrence and its scope dialog, quick add, calendar and zone settings) on kloq's own model, drawn with native controls — SwiftUI via `@expo/ui` (Liquid Glass via `expo-glass-effect`) on iOS, Jetpack Compose on Android. MIT. See `packages/expo-kloq/AGENTS.md`. | | `tooling/typescript` | `@coss/tsconfig` | Shared tsconfig bases. | | `tooling/sdkgen` | `@coss/sdkgen` | Builds the generated SDKs: `SDK_METHODS` (every operation's SDK method, named as in the TypeScript SDK) and `NO_RETRY` (`src/methods.ts`), the derive step that turns `apps/api/openapi.json` into the spec Fern generates from (`src/derive.ts`), the post-processing of each generator's output (`src/languages.ts`), the API reference's code samples (`snippets.json`, from each SDK's `reference.md`); `scripts/generate.ts` (`bun run sdks:generate`, needs Docker). See `sdks/AGENTS.md`. | | `sdks/{python,go,ruby,php,java,dotnet}` | `cosscom` (PyPI, `import coss`), `github.com/cosscom/coss-go`, `coss` (gem), `coss/coss` (Composer), `com.coss:coss` (Maven), `Coss` (NuGet) | **Public** SDKs generated from the OpenAPI document by Fern's open-source generators (`sdks/fern/generators.yml`), each with a few hand-written files kept by its `.fernignore` (webhook verification, tests, metadata). Not workspaces. Regenerated on main by the "Regenerate SDKs" PR; released by `release-sdks.yml`. See `sdks/AGENTS.md`. | @@ -95,7 +97,7 @@ billing enqueues `billing.*` events through it — and email imports only `./met `smtp → tenancy, email, ratelimit, logs, billing, observability, db`; `web → auth, tenancy, email, sms, sign, forms, support, hooks, reputation, screening, webhooks, ratelimit, versioning, retention, logs, billing, risk, push, migrate, companion, db, ui`; `mobile → companion` (types only; a dev dependency), `retention`, `observability`, `dashboard-routes` (bundled by Metro: keep them dependency-free); `portal → hooks, webhooks, ratelimit, logs, observability, db, ui`; `sign-app → sign, ratelimit, observability, db, ui`; `site → ui, auth, versioning, retention, billing, risk, reputation, ratelimit, sms, sdkgen` (only the client-safe `@coss/auth/{slugs,constants,api-key-permissions}`, the changelog, the retention policy, `@coss/billing/{catalog,money,engine}`, `@coss/risk/schemas`, `@coss/reputation/schemas` (the default policy), `@coss/ratelimit/buckets` (the bucket catalog, no imports), `@coss/sms/segments` (the segment calculator) and `@coss/sdkgen`'s `snippets.json` + `./methods`) + `@coss/api/openapi.json` (data only, never api code); `sdkgen → @coss/api/openapi.json` (data only); `infra → auth, email, sms, sign, hooks, ratelimit, screening, billing` (`slugs`, `env` only); -`worker → auth (`./emails` only), email, sms, sign, verify, forms, support, hooks, reputation, screening, webhooks, ratelimit, logs, risk, push, migrate, tenancy, retention, billing, db` (from tenancy only `./retention`, `./deletion` and `./logos`, which import nothing but `db` and `signals`); `sdk`, `ui`, `kloq` and `companion` depend on no workspace (`site → kloq`); `mcp` and `cli` depend only on the published `coss` (a +`worker → auth (`./emails` only), email, sms, sign, verify, forms, support, hooks, reputation, screening, webhooks, ratelimit, logs, risk, push, migrate, tenancy, retention, billing, db` (from tenancy only `./retention`, `./deletion` and `./logos`, which import nothing but `db` and `signals`); `sdk`, `ui`, `kloq` and `companion` depend on no workspace (`site → kloq`, `expo-kloq → kloq`, `expo-kloq-example → expo-kloq`); `mcp` and `cli` depend only on the published `coss` (a semver range, not `workspace:`). Dev-only: `email`/`sms`/`verify`/`forms`/`webhooks`/`hooks`/`reputation`/`screening`/`ratelimit`/`logs`/`billing`/`risk`/`push`/`notifications`/`migrate`/`worker → tenancy` (test fixtures — so tenancy and auth must never import email, webhooks, screening, ratelimit, logs, billing or notifications (the layout they share is the dependency-free `@coss/email-layout`) (plan limits reach Better Auth through `setMembershipLimitResolver` from `@coss/auth/limits`); Better Auth's own audit events reach the log through `setOrgAuditRecorder` from `@coss/auth/audit`) and `api`/`worker`/`site → coss` and `api → coss-cli` (SDK, MCP and CLI contract tests and the docs' Twoslash snippets — turbo builds the SDK before their diff --git a/apps/expo-kloq-example/.gitignore b/apps/expo-kloq-example/.gitignore new file mode 100644 index 000000000..8f80cee16 --- /dev/null +++ b/apps/expo-kloq-example/.gitignore @@ -0,0 +1,5 @@ +# Continuous Native Generation: `expo prebuild` writes these from app.json and plugins/ +ios/ +android/ +.expo/ +logs/ diff --git a/apps/expo-kloq-example/README.md b/apps/expo-kloq-example/README.md new file mode 100644 index 000000000..4c23427d3 --- /dev/null +++ b/apps/expo-kloq-example/README.md @@ -0,0 +1,25 @@ +# expo-kloq example + +A development build (not Expo Go: `@expo/ui` and `expo-glass-effect` are native modules) that shows +`` from `packages/expo-kloq` with four seeded calendars and a few weeks of events placed +relative to today. + +```sh +bun install # repo root +bun run --cwd apps/expo-kloq-example prebuild -- -p ios # writes ios/ from app.json + plugins/ +bun run --cwd apps/expo-kloq-example ios # build, install, start Metro +``` + +`ios/` and `android/` are generated (Continuous Native Generation): change `app.json` or +`plugins/` and prebuild again, never edit them. If `pod install` fails with a Unicode +normalization error, run it with `LANG=en_US.UTF-8`. + +- **iOS 27** asserts at launch unless the app uses the scene life cycle, which SDK 57's prebuild + template doesn't. `plugins/with-scene-delegate.js` makes the AppDelegate an + `ExpoReactNativeFactoryProvider` and points `UIApplicationSceneManifest` at Expo's + `ExpoAppSceneDelegate`, which starts React Native in the scene's window. +- **Metro** (`metro.config.js`) pins React, React Native and the native modules' JS to this app, so + kloq's sources (whose workspace has the web apps' React) share one React. +- **Demo**: `maestro/demo.yaml` walks every interaction. Run it on a Release build + (`bunx expo run:ios --configuration Release`): a debug build's dev-client button covers the + sheets' Save button. diff --git a/apps/expo-kloq-example/app.json b/apps/expo-kloq-example/app.json new file mode 100644 index 000000000..f954681b5 --- /dev/null +++ b/apps/expo-kloq-example/app.json @@ -0,0 +1,20 @@ +{ + "expo": { + "name": "kloq", + "slug": "expo-kloq-example", + "version": "1.0.0", + "orientation": "portrait", + "scheme": "expokloq", + "userInterfaceStyle": "automatic", + "platforms": ["ios", "android"], + "ios": { + "bundleIdentifier": "com.coss.kloq.example", + "supportsTablet": true, + "config": { "usesNonExemptEncryption": false } + }, + "android": { + "package": "com.coss.kloq.example" + }, + "plugins": ["./plugins/with-scene-delegate.js"] + } +} diff --git a/apps/expo-kloq-example/biome.json b/apps/expo-kloq-example/biome.json new file mode 100644 index 000000000..ed01b2bda --- /dev/null +++ b/apps/expo-kloq-example/biome.json @@ -0,0 +1,8 @@ +{ + "root": false, + "$schema": "https://biomejs.dev/schemas/2.5.15/schema.json", + "extends": "//", + "linter": { + "domains": { "react": "recommended" } + } +} diff --git a/apps/expo-kloq-example/index.ts b/apps/expo-kloq-example/index.ts new file mode 100644 index 000000000..135f3ed66 --- /dev/null +++ b/apps/expo-kloq-example/index.ts @@ -0,0 +1,4 @@ +import { registerRootComponent } from "expo"; +import { App } from "./src/app"; + +registerRootComponent(App); diff --git a/apps/expo-kloq-example/maestro/demo.yaml b/apps/expo-kloq-example/maestro/demo.yaml new file mode 100644 index 000000000..e82d88640 --- /dev/null +++ b/apps/expo-kloq-example/maestro/demo.yaml @@ -0,0 +1,157 @@ +# A paced walkthrough of every interaction, for recording a demo on the iOS Simulator: +# xcrun simctl io booted recordVideo --codec=h264 demo.mov (in another terminal) +# JAVA_HOME=… maestro test maestro/demo.yaml +# Run it against a Release build (`expo run:ios --configuration Release`): a debug build's +# dev-client button sits over the sheets' Save button. Seeds are relative to today. +appId: com.coss.kloq.example +--- +- stopApp +- launchApp +- extendedWaitUntil: + visible: "Quick add" + timeout: 30000 +- runFlow: pause.yaml + +# ── navigation ────────────────────────────────────────────────────────────── +- tapOn: "Next week" +- runFlow: pause.yaml +- tapOn: "Next week" +- runFlow: pause.yaml +- tapOn: "Previous week" +- runFlow: pause.yaml +- tapOn: "Today" +- runFlow: pause.yaml + +# ── views ─────────────────────────────────────────────────────────────────── +- tapOn: "Day" +- runFlow: pause.yaml +- tapOn: "Next day" +- runFlow: pause.yaml +- tapOn: "Today" +- tapOn: "Month" +- runFlow: pause.yaml +- runFlow: pause.yaml +- tapOn: "Year" +- runFlow: pause.yaml +- scroll +- runFlow: pause.yaml +- tapOn: "Schedule" +- runFlow: pause.yaml +- scroll +- runFlow: pause.yaml +- tapOn: "More" +- tapOn: "View" +- tapOn: "Timeline" +- runFlow: pause.yaml +- runFlow: pause.yaml +- tapOn: "More" +- tapOn: "View" +- tapOn: "3 days" +- runFlow: pause.yaml +- tapOn: "Week" +- runFlow: pause.yaml + +# ── the event sheet: native pickers, all-day, calendar, recurrence, alerts ─ +- tapOn: "Design review, .*" +- runFlow: pause.yaml +- tapOn: "13:00" +- runFlow: pause.yaml +- swipe: + start: "51%,62%" + end: "51%,58%" +- runFlow: pause.yaml +- tapOn: + point: "50%,12%" +- runFlow: pause.yaml +- tapOn: + point: "57%,45%" +- runFlow: pause.yaml +- runFlow: pause.yaml +- tapOn: + point: "50%,12%" +- tapOn: + point: "84%,38%" +- runFlow: pause.yaml +- tapOn: + point: "84%,38%" +- runFlow: pause.yaml +- tapOn: "Team" +- runFlow: pause.yaml +- tapOn: "Work" +- runFlow: pause.yaml +- tapOn: "Does not repeat" +- runFlow: pause.yaml +- tapOn: "Daily" +- runFlow: pause.yaml +- tapOn: "Daily" +- runFlow: pause.yaml +- tapOn: "Custom…" +- runFlow: pause.yaml +- scroll +- runFlow: pause.yaml +- tapOn: "Weekly" +- runFlow: pause.yaml +- tapOn: "Wednesday" +- runFlow: pause.yaml +- tapOn: "Every week, Increment" +- runFlow: pause.yaml +- tapOn: "Add alert" +- runFlow: pause.yaml +- tapOn: "Save" +- runFlow: pause.yaml + +# ── a repeating event: the "this / this and following / all" dialog ────── +- tapOn: + text: "Standup, .*" + index: 2 +- runFlow: pause.yaml +- tapOn: "Standup" +- inputText: " (moved)" +- tapOn: "Save" +- runFlow: pause.yaml +- tapOn: "This event" +- runFlow: pause.yaml +- tapOn: + text: "Standup, .*" + index: 3 +- runFlow: pause.yaml +- scroll +- tapOn: "Delete event" +- runFlow: pause.yaml +- tapOn: "This and following events" +- runFlow: pause.yaml + +# ── new event and quick add ───────────────────────────────────────────────── +- tapOn: "New event" +- runFlow: pause.yaml +- tapOn: "Title" +- inputText: "Focus time" +- runFlow: pause.yaml +- tapOn: "Add" +- runFlow: pause.yaml +- tapOn: "Quick add" +- runFlow: pause.yaml +- inputText: "Coffee with Dana tomorrow 3pm at Monmouth" +- runFlow: pause.yaml +- runFlow: pause.yaml +- tapOn: "Add" +- runFlow: pause.yaml + +# ── calendar toggles and the multi-zone gutter ───────────────────────────── +- tapOn: "More" +- tapOn: "Calendars & time zones" +- runFlow: pause.yaml +- tapOn: + point: "84%,29%" +- runFlow: pause.yaml +- tapOn: + point: "84%,77%" +- runFlow: pause.yaml +- tapOn: "Done" +- runFlow: pause.yaml +- tapOn: "Day" +- runFlow: pause.yaml +- runFlow: pause.yaml +- tapOn: "Week" +- runFlow: pause.yaml +- runFlow: pause.yaml diff --git a/apps/expo-kloq-example/maestro/pause.yaml b/apps/expo-kloq-example/maestro/pause.yaml new file mode 100644 index 000000000..6bfa312a4 --- /dev/null +++ b/apps/expo-kloq-example/maestro/pause.yaml @@ -0,0 +1,7 @@ +# A beat between demo steps, so the recording is watchable. +appId: com.coss.kloq.example +--- +- extendedWaitUntil: + visible: "__pause__" + timeout: 1800 + optional: true diff --git a/apps/expo-kloq-example/maestro/screenshots.yaml b/apps/expo-kloq-example/maestro/screenshots.yaml new file mode 100644 index 000000000..2514a7e12 --- /dev/null +++ b/apps/expo-kloq-example/maestro/screenshots.yaml @@ -0,0 +1,90 @@ +# One screenshot per main view and sheet (Release build; see demo.yaml). Maestro writes them under +# its test output folder (`--test-output-dir`). +appId: com.coss.kloq.example +--- +- stopApp +- launchApp +- extendedWaitUntil: + visible: "Quick add" + timeout: 30000 +- waitForAnimationToEnd +- takeScreenshot: 01-week +- tapOn: "Day" +- waitForAnimationToEnd +- takeScreenshot: 02-day +- tapOn: "More" +- tapOn: "View" +- tapOn: "3 days" +- waitForAnimationToEnd +- takeScreenshot: 03-three-days +- tapOn: "Month" +- waitForAnimationToEnd +- takeScreenshot: 04-month +- tapOn: "Year" +- waitForAnimationToEnd +- takeScreenshot: 05-year +- tapOn: "Schedule" +- waitForAnimationToEnd +- takeScreenshot: 06-schedule +- tapOn: "More" +- tapOn: "View" +- tapOn: "Timeline" +- waitForAnimationToEnd +- takeScreenshot: 07-timeline +- tapOn: "Week" +- tapOn: "Design review, .*" +- waitForAnimationToEnd +- takeScreenshot: 08-event-sheet +- tapOn: "13:00" +- waitForAnimationToEnd +- takeScreenshot: 09-time-picker +- tapOn: + point: "50%,12%" +- tapOn: + point: "57%,45%" +- waitForAnimationToEnd +- takeScreenshot: 10-date-picker +- tapOn: + point: "50%,12%" +- tapOn: "Does not repeat" +- waitForAnimationToEnd +- takeScreenshot: 11-repeat-menu +- tapOn: "Custom…" +- tapOn: "Weekly" +- tapOn: "Wednesday" +- waitForAnimationToEnd +- takeScreenshot: 12-custom-recurrence +- tapOn: "Cancel" +- waitForAnimationToEnd +- tapOn: + text: "Standup, .*" + index: 2 +- waitForAnimationToEnd +- tapOn: "Save" +- waitForAnimationToEnd +- takeScreenshot: 13-scope-dialog +- tapOn: + point: "50%,95%" +- waitForAnimationToEnd +- tapOn: "Cancel" +- waitForAnimationToEnd +- tapOn: "Quick add" +- inputText: "Coffee with Dana tomorrow 3pm at Monmouth" +- waitForAnimationToEnd +- takeScreenshot: 14-quick-add +- tapOn: "Cancel" +- waitForAnimationToEnd +- tapOn: "More" +- waitForAnimationToEnd +- takeScreenshot: 15-menu +- tapOn: "Calendars & time zones" +- waitForAnimationToEnd +- tapOn: + point: "84%,77%" +- tapOn: + point: "84%,64%" +- waitForAnimationToEnd +- takeScreenshot: 16-calendars-and-zones +- tapOn: "Done" +- waitForAnimationToEnd +- takeScreenshot: 17-multi-zone-gutter diff --git a/apps/expo-kloq-example/metro.config.js b/apps/expo-kloq-example/metro.config.js new file mode 100644 index 000000000..f7046c7fd --- /dev/null +++ b/apps/expo-kloq-example/metro.config.js @@ -0,0 +1,32 @@ +// Bun's isolated linker gives every workspace its own `node_modules`, so `kloq`'s sources would +// resolve `react` to the web apps' React (the root catalog's) and `expo-kloq` to its own dev copy. +// A React Native bundle must hold exactly one React and one copy of each native module's JS, so +// these packages always resolve from this app, whoever imports them. +const path = require("node:path"); +const { getDefaultConfig } = require("expo/metro-config"); + +const config = getDefaultConfig(__dirname); + +const SINGLETONS = [ + "react", + "react-native", + "expo", + "@expo/ui", + "expo-glass-effect", + "react-native-safe-area-context", +]; +const appOrigin = path.join(__dirname, "index.ts"); + +const upstream = config.resolver.resolveRequest; +config.resolver.resolveRequest = (context, moduleName, platform) => { + const resolve = upstream ?? context.resolveRequest; + const singleton = SINGLETONS.some( + (name) => moduleName === name || moduleName.startsWith(`${name}/`), + ); + if (singleton && context.originModulePath !== appOrigin) { + return resolve({ ...context, originModulePath: appOrigin }, moduleName, platform); + } + return resolve(context, moduleName, platform); +}; + +module.exports = config; diff --git a/apps/expo-kloq-example/package.json b/apps/expo-kloq-example/package.json new file mode 100644 index 000000000..f39a0c987 --- /dev/null +++ b/apps/expo-kloq-example/package.json @@ -0,0 +1,37 @@ +{ + "name": "@coss/expo-kloq-example", + "version": "0.0.0", + "private": true, + "main": "index.ts", + "scripts": { + "start": "EXPO_NO_TYPESCRIPT_SETUP=1 expo start --dev-client", + "ios": "EXPO_NO_TYPESCRIPT_SETUP=1 expo run:ios", + "android": "EXPO_NO_TYPESCRIPT_SETUP=1 expo run:android", + "prebuild": "EXPO_NO_TYPESCRIPT_SETUP=1 expo prebuild", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@expo/ui": "57.0.25", + "expo": "~57.0.29", + "expo-dev-client": "~57.0.20", + "expo-glass-effect": "57.0.4", + "expo-status-bar": "~57.0.1", + "expo-kloq": "workspace:*", + "react": "19.2.3", + "react-native": "0.86.3", + "react-native-safe-area-context": "~5.7.0" + }, + "devDependencies": { + "@coss/tsconfig": "workspace:*", + "@expo/config-plugins": "~57.0.10", + "@types/react": "~19.2.2", + "typescript": "catalog:" + }, + "expo": { + "install": { + "exclude": [ + "typescript" + ] + } + } +} diff --git a/apps/expo-kloq-example/plugins/with-scene-delegate.js b/apps/expo-kloq-example/plugins/with-scene-delegate.js new file mode 100644 index 000000000..097c2f0ff --- /dev/null +++ b/apps/expo-kloq-example/plugins/with-scene-delegate.js @@ -0,0 +1,50 @@ +// iOS 27 asserts at launch unless the app adopts the scene-based life cycle, and SDK 57's prebuild +// template still starts React Native from the app delegate. This plugin moves the start into +// Expo's own `ExpoAppSceneDelegate` (Objective-C name `EXExpoAppSceneDelegate`, so Info.plist can +// name it without adding a Swift file): the app delegate keeps creating the factory and hands it +// over through `ExpoReactNativeFactoryProvider`. +const { withAppDelegate, withInfoPlist } = require("@expo/config-plugins"); + +const WINDOW_START = + /\n#if os\(iOS\) \|\| os\(tvOS\)\n\s*window = UIWindow\(frame: UIScreen\.main\.bounds\)[\s\S]*?#endif\n/; + +function withSceneAppDelegate(config) { + return withAppDelegate(config, (mod) => { + if (mod.modResults.language !== "swift") { + throw new Error("with-scene-delegate: expected a Swift AppDelegate"); + } + let src = mod.modResults.contents; + if (!src.includes("ExpoReactNativeFactoryProvider")) { + src = src.replace( + "class AppDelegate: ExpoAppDelegate {", + "class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider {", + ); + } + // the scene delegate creates the window and starts React Native into it + src = src.replace(WINDOW_START, "\n"); + if (!src.includes("ExpoReactNativeFactoryProvider") || src.includes("UIScreen.main.bounds")) { + throw new Error("with-scene-delegate: the AppDelegate template changed; update the plugin"); + } + mod.modResults.contents = src; + return mod; + }); +} + +function withSceneManifest(config) { + return withInfoPlist(config, (mod) => { + mod.modResults.UIApplicationSceneManifest = { + UIApplicationSupportsMultipleScenes: false, + UISceneConfigurations: { + UIWindowSceneSessionRoleApplication: [ + { + UISceneConfigurationName: "Default Configuration", + UISceneDelegateClassName: "EXExpoAppSceneDelegate", + }, + ], + }, + }; + return mod; + }); +} + +module.exports = (config) => withSceneManifest(withSceneAppDelegate(config)); diff --git a/apps/expo-kloq-example/src/app.tsx b/apps/expo-kloq-example/src/app.tsx new file mode 100644 index 000000000..b9746083c --- /dev/null +++ b/apps/expo-kloq-example/src/app.tsx @@ -0,0 +1,22 @@ +import { KloqCalendar } from "expo-kloq"; +import { StatusBar } from "expo-status-bar"; +import { useState } from "react"; +import { SafeAreaProvider } from "react-native-safe-area-context"; +import { ACCOUNTS, CALENDARS, seedEvents } from "./seed"; + +export function App() { + // seeded relative to today, so "Today" always lands on events + const [events] = useState(() => seedEvents()); + return ( + + + + + ); +} diff --git a/apps/expo-kloq-example/src/seed.ts b/apps/expo-kloq-example/src/seed.ts new file mode 100644 index 000000000..8625910a2 --- /dev/null +++ b/apps/expo-kloq-example/src/seed.ts @@ -0,0 +1,136 @@ +import { + buildSeedEvents, + type CalEvent, + type CalendarAccount, + type CalendarSource, +} from "expo-kloq"; + +export const ACCOUNTS: CalendarAccount[] = [ + { + id: "work", + label: "work@example.com", + provider: "google", + providerLabel: "Google", + isDefault: true, + }, + { id: "home", label: "me@example.com", provider: "icloud", providerLabel: "iCloud" }, +]; + +export const CALENDARS: CalendarSource[] = [ + { id: "work", name: "Work", color: "blue", accountId: "work", isDefault: true }, + { id: "team", name: "Team", color: "violet", accountId: "work" }, + { id: "personal", name: "Personal", color: "emerald", accountId: "home" }, + { id: "holidays", name: "Holidays", color: "rose", accountId: "home", readOnly: true }, +]; + +/** Which calendar each of kloq's own seed events belongs to. */ +const CALENDAR_OF: Record = { + "seed-standup": "work", + "seed-design": "team", + "seed-deep": "work", + "seed-interview": "team", + "seed-vendor": "work", + "seed-oneone": "work", + "seed-lunch": "personal", + "seed-release": "team", + "seed-ooo": "holidays", +}; + +const pad = (n: number) => String(n).padStart(2, "0"); + +/** A local wall-clock time `days` from today, as an offset-qualified instant. */ +function at(today: Date, days: number, hour: number, minute = 0): string { + const d = new Date(today.getFullYear(), today.getMonth(), today.getDate() + days, hour, minute); + const off = -d.getTimezoneOffset(); + const sign = off < 0 ? "-" : "+"; + const zone = + off === 0 ? "Z" : `${sign}${pad(Math.floor(Math.abs(off) / 60))}:${pad(Math.abs(off) % 60)}`; + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:00${zone}`; +} + +function day(today: Date, days: number): string { + const d = new Date(today.getFullYear(), today.getMonth(), today.getDate() + days); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +/** kloq's sample week (anchored on this week) plus a few weeks of events around today. */ +export function seedEvents(today: Date = new Date()): CalEvent[] { + const week = buildSeedEvents(today).map((e) => ({ + ...e, + calendarId: CALENDAR_OF[e.id] ?? "work", + })); + const more: CalEvent[] = [ + { + id: "ex-gym", + title: "Gym", + calendarId: "personal", + rrule: "FREQ=WEEKLY;BYDAY=TU,TH", + start: at(today, -14, 7, 0), + end: at(today, -14, 8, 0), + }, + { + id: "ex-planning", + title: "Sprint planning", + calendarId: "team", + rrule: "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO", + start: at(today, -21, 10, 0), + end: at(today, -21, 11, 30), + location: "Room 2", + reminders: [{ minutesBefore: 15 }], + }, + { + id: "ex-dentist", + title: "Dentist", + calendarId: "personal", + start: at(today, 2, 16, 0), + end: at(today, 2, 16, 45), + }, + { + id: "ex-offsite", + title: "Team offsite", + calendarId: "team", + allDay: true, + start: day(today, 9), + end: day(today, 11), + location: "Lisbon", + }, + { + id: "ex-review", + title: "Quarterly review", + calendarId: "work", + start: at(today, 12, 14, 0), + end: at(today, 12, 15, 30), + }, + { + id: "ex-dinner", + title: "Dinner with Alex", + calendarId: "personal", + start: at(today, 5, 19, 30), + end: at(today, 5, 21, 0), + }, + { + id: "ex-flight", + title: "Flight to Berlin", + calendarId: "personal", + start: at(today, 18, 8, 15), + end: at(today, 18, 11, 0), + }, + { + id: "ex-holiday", + title: "Public holiday", + calendarId: "holidays", + allDay: true, + readOnly: true, + start: day(today, 24), + end: day(today, 25), + }, + { + id: "ex-call", + title: "Call with Priya", + calendarId: "work", + start: at(today, 0, 17, 0), + end: at(today, 0, 17, 30), + }, + ]; + return [...week, ...more]; +} diff --git a/apps/expo-kloq-example/tsconfig.json b/apps/expo-kloq-example/tsconfig.json new file mode 100644 index 000000000..40b7c93c2 --- /dev/null +++ b/apps/expo-kloq-example/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@coss/tsconfig/base.json", + "compilerOptions": { + "lib": ["DOM", "ESNext"], + "jsx": "react-jsx", + "customConditions": ["react-native"], + // expo-kloq type-checks kloq's sources through its imports; they are written without it + "noUncheckedIndexedAccess": false + }, + "include": ["index.ts", "src"] +} diff --git a/bun.lock b/bun.lock index 08241bd34..6c48eb020 100644 --- a/bun.lock +++ b/bun.lock @@ -51,6 +51,27 @@ "typescript": "catalog:", }, }, + "apps/expo-kloq-example": { + "name": "@coss/expo-kloq-example", + "version": "0.0.0", + "dependencies": { + "@expo/ui": "57.0.25", + "expo": "~57.0.29", + "expo-dev-client": "~57.0.20", + "expo-glass-effect": "57.0.4", + "expo-kloq": "workspace:*", + "expo-status-bar": "~57.0.1", + "react": "19.2.3", + "react-native": "0.86.3", + "react-native-safe-area-context": "~5.7.0", + }, + "devDependencies": { + "@coss/tsconfig": "workspace:*", + "@expo/config-plugins": "~57.0.10", + "@types/react": "~19.2.2", + "typescript": "catalog:", + }, + }, "apps/mobile": { "name": "@coss/mobile", "version": "0.0.0", @@ -571,6 +592,32 @@ "typescript": "catalog:", }, }, + "packages/expo-kloq": { + "name": "expo-kloq", + "version": "0.1.0", + "dependencies": { + "kloq": "workspace:*", + }, + "devDependencies": { + "@coss/tsconfig": "workspace:*", + "@expo/ui": "57.0.25", + "@types/react": "~19.2.2", + "expo": "~57.0.29", + "expo-glass-effect": "57.0.4", + "react": "19.2.3", + "react-native": "0.86.3", + "react-native-safe-area-context": "~5.7.0", + "typescript": "catalog:", + }, + "peerDependencies": { + "@expo/ui": "~57.0.25", + "expo": "~57.0.29", + "expo-glass-effect": "~57.0.4", + "react": "19.2.3", + "react-native": "0.86.3", + "react-native-safe-area-context": "~5.7.0", + }, + }, "packages/forms": { "name": "@coss/forms", "version": "0.0.0", @@ -1536,6 +1583,8 @@ "@coss/email-layout": ["@coss/email-layout@workspace:packages/email-layout"], + "@coss/expo-kloq-example": ["@coss/expo-kloq-example@workspace:apps/expo-kloq-example"], + "@coss/forms": ["@coss/forms@workspace:packages/forms"], "@coss/hooks": ["@coss/hooks@workspace:packages/hooks"], @@ -2508,7 +2557,7 @@ "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "anynum": ["anynum@1.0.1", "", {}, "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A=="], @@ -2808,6 +2857,14 @@ "expo-crypto": ["expo-crypto@57.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-LqMMs/N6wvwZyw4deVmcl3AgXgqMwCVQ9WyWf2eaWyrk99TXSKk5da3QspiLOOjnn5Qv9f8C50cDNuoyLThpdw=="], + "expo-dev-client": ["expo-dev-client@57.0.20", "", { "dependencies": { "expo-dev-launcher": "~57.0.21", "expo-dev-menu": "~57.0.20", "expo-dev-menu-interface": "~57.0.0", "expo-manifests": "~57.0.2", "expo-updates-interface": "~57.0.2" }, "peerDependencies": { "expo": "*" } }, "sha512-ko5FzWsUPuG4aiQfmqxQA5yAdmal6SSICB70KJLChQYzWo+XDoTpXRgEpzQSr1Ku8KyKW0lkEa48xbnKSgd1xQ=="], + + "expo-dev-launcher": ["expo-dev-launcher@57.0.21", "", { "dependencies": { "@expo/schema-utils": "^57.0.2", "expo-dev-menu": "~57.0.20", "expo-manifests": "~57.0.2" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-dXa1bhCEtSXn4y34rydBvArmoZtcLK7zY0FN3x1JFPTGh81OXUGZWhyzLCAI9nhI23L/MTy7cGCeNyeJmC1ITw=="], + + "expo-dev-menu": ["expo-dev-menu@57.0.20", "", { "dependencies": { "expo-dev-menu-interface": "~57.0.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-xpf7Rkj++sBA285GqWVJTtl5j/TychD980GKqWA5lVFKM41+xUNmyPMOfO18heAKvX9i620ZHVoHiBGD+6pCdA=="], + + "expo-dev-menu-interface": ["expo-dev-menu-interface@57.0.0", "", { "peerDependencies": { "expo": "*" } }, "sha512-F47VdzOHYc19FhI/jBgctpO8a5UskTIxG6a1E5t3W5gF8VImuvBQffdXXfLHhsuCl7dS3v3U0R45cleeVXO1Zg=="], + "expo-file-system": ["expo-file-system@57.0.7", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-1vkZRadOsIputFDBcclqBa1pO3iXOO6iIsdqJipQWw4V5TSnWgmRTuOF7ro8IzrVue+PHbum++KCG3Tawem4cg=="], "expo-font": ["expo-font@57.0.4", "", { "dependencies": { "fontfaceobserver": "^2.1.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-7WOMC2oA6xCfOm8bbofqWn2bIqyWFm7YLEnpc/gA1MJH0Ll0TfT/3+CqRkoW2eNRWMS8sxrz5WukUmSvmNquHA=="], @@ -2816,10 +2873,16 @@ "expo-haptics": ["expo-haptics@57.0.3", "", { "peerDependencies": { "expo": "*" } }, "sha512-qucUXtwtJdtECKSQK5D6TQBc/0SxKj7s5wQ4mrfSxhZsHrS9a4rcxINl/gbruMaY9spTXStco32ST4pV0vXmxw=="], + "expo-json-utils": ["expo-json-utils@57.0.2", "", {}, "sha512-9kYZECLKE3pYZQs+kt3+ggY83Lfryog9/UEo3HJKGcHUZeKKtlFBIPFjI4LzREubsvjP7AsvG0FdEX9EquIoAQ=="], + "expo-keep-awake": ["expo-keep-awake@57.0.2", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-GqgH746wtJImmsbyHmCQoi7cOslKuWQBbJHXr53S35Bpf5UvxCAztvnitUEd7D0QvFN2rvkOhylbZpgZZfBLlQ=="], + "expo-kloq": ["expo-kloq@workspace:packages/expo-kloq"], + "expo-linking": ["expo-linking@57.0.13", "", { "dependencies": { "expo-constants": "~57.0.22", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-1cjN7Ely0FZqlZItfPYjl6jjrCFLRevrIE/H5XhA6tgDm9uhTaSyJQXyftgt2FvJZ7liheDft7WMnPB9Uktx+A=="], + "expo-manifests": ["expo-manifests@57.0.2", "", { "dependencies": { "expo-json-utils": "~57.0.2" }, "peerDependencies": { "expo": "*" } }, "sha512-pPIIcKR73OShwlWMg8H90WRzKUd2krDdKlKAwuQV1FtUwE7H3hQEFw0ePFQY9yFoGMF/77ryYwNKfI5umMcCog=="], + "expo-modules-autolinking": ["expo-modules-autolinking@57.0.16", "", { "dependencies": { "@expo/require-utils": "^57.0.6", "@expo/spawn-async": "^1.8.0", "chalk": "^4.1.0", "commander": "^7.2.0" }, "bin": { "expo-modules-autolinking": "bin/expo-modules-autolinking.js" } }, "sha512-OJh6rMMIIZ/dfwOk2FufJjjsUSw5Boy1Y5Tl+zbBmua/FcD03eEg097dkYIf3iBHVkQ6q9eR08qT88qxvnelow=="], "expo-modules-core": ["expo-modules-core@57.0.23", "", { "dependencies": { "@expo/expo-modules-macros-plugin": "0.6.1", "expo-modules-jsi": "~57.1.2", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-worklets": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0" }, "optionalPeers": ["react-native-worklets"] }, "sha512-vZQoSlhqU6me5K4KCl42yXB0x3zqAiG2FAjzHmbuez77CslJJQ+82cmJoHC3tN5X8o/KxRArJejKNQIeciyoXA=="], @@ -2838,6 +2901,8 @@ "expo-symbols": ["expo-symbols@57.0.3", "", { "dependencies": { "@expo-google-fonts/material-symbols": "^0.4.1", "sf-symbols-typescript": "^2.0.0" }, "peerDependencies": { "expo": "*", "expo-font": "*", "react": "*", "react-native": "*" } }, "sha512-uuK5yHLVR7kExUCrO/ItBvp/kp1maQ9Nkb6KvNy5ZUQnN0NMKwuhJ4GvbfS9N3ACmvCjTT4gKvg4yH5UuKIYNA=="], + "expo-updates-interface": ["expo-updates-interface@57.0.2", "", { "peerDependencies": { "expo": "*" } }, "sha512-x3SA5toiHZEkW+5oeNdUM1Rvt6M3ExegQsSwS3Z1+S9gn9utGYTGBkb8MBQivhjIZiRlQYDIhauxA8kySQEWtQ=="], + "expo-web-browser": ["expo-web-browser@57.0.3", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-+ecvhyS/PdWkpv4ai6FF1Trd6Y/4kn/eQ3O86mie2ecU3w+I8bKZh13kppY/jw1uSL7yikt03L5W/iiHojrFwQ=="], "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], @@ -3684,7 +3749,7 @@ "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "@babel/core": "*", "babel-plugin-macros": "*", "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" }, "optionalPeers": ["@babel/core", "babel-plugin-macros"] }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], - "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "supports-hyperlinks": ["supports-hyperlinks@2.3.0", "", { "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" } }, "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA=="], @@ -4058,6 +4123,8 @@ "@sentry/browser/@sentry/core": ["@sentry/core@10.37.0", "", {}, "sha512-hkRz7S4gkKLgPf+p3XgVjVm7tAfvcEPZxeACCC6jmoeKhGkzN44nXwLiqqshJ25RMcSrhfFvJa/FlBg6zupz7g=="], + "@sentry/bundler-plugins/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + "@sentry/cloudflare/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "@sentry/nextjs/@sentry/react": ["@sentry/react@11.6.0", "", { "dependencies": { "@sentry/browser": "11.6.0", "@sentry/conventions": "^0.26.0", "@sentry/core": "11.6.0" }, "peerDependencies": { "react": "17.x || 18.x || 19.x", "react-router": "6.x || 7.x || 8.x" }, "optionalPeers": ["react-router"] }, "sha512-z9UScxzkfXjz7dGJW/or1ADiEStFPIsiv7u14vHonOPPFzQS3XClWafcS7AKc4g/9x6LUqGY4mMbHLC6Dfxq8A=="], @@ -4156,6 +4223,8 @@ "jest-worker/@types/node": ["@types/node@26.6.5", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="], + "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + "kloq/@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="], "kloq/react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="], @@ -4206,6 +4275,8 @@ "pkijs/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + "pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], "react-dom/react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="], @@ -4234,16 +4305,12 @@ "strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="], - "supports-hyperlinks/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "tsx/esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], - "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "xml-crypto/@xmldom/xmldom": ["@xmldom/xmldom@0.8.15", "", {}, "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA=="], @@ -4258,54 +4325,10 @@ "yuku-parser/yuku-ast": ["yuku-ast@0.10.2", "", { "dependencies": { "@yuku-toolchain/types": "^0.10.2" } }, "sha512-UnG9mA6giglCvSErft2/40TVFX750Sj1xgwddPLpG7J7rlr/P1wPADg9G2RK+d/1tNLtRVoLdMMOMVBB9561TQ=="], - "@expo/cli/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - - "@expo/cli/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - - "@expo/config-plugins/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - - "@expo/config-plugins/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - - "@expo/devtools/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - - "@expo/devtools/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - - "@expo/env/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - - "@expo/env/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - - "@expo/fingerprint/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - - "@expo/fingerprint/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - - "@expo/image-utils/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - - "@expo/image-utils/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - - "@expo/local-build-cache-provider/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - - "@expo/local-build-cache-provider/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - - "@expo/metro-config/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - - "@expo/metro-config/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "@expo/metro/metro-source-map/ob1": ["ob1@0.84.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-aH9RkoZc7w/90HBamFxTw8ZLFr05wXS+iOnvmrgo53Ep8Pyrm5FieQSaPIVROkfFVQISeD/zo92fes26TOwe+A=="], - "@expo/package-manager/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - - "@expo/package-manager/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - - "@expo/xcpretty/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - - "@expo/xcpretty/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "@jest/types/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], - "@jest/types/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - - "@jest/types/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "@react-native/babel-preset/@react-native/babel-plugin-codegen/@react-native/codegen": ["@react-native/codegen@0.88.0", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/parser": "^7.29.0", "flow-parser": "0.327.0", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "tinyglobby": "^0.2.15", "yargs": "^17.6.2" } }, "sha512-SAGg+XfzsrPiYTscbKa9bo2/H2ei2TjyxW8RTMq2Ps661kRK3AnmpMGVfrEFshEMr3FGEbuQIvj86X98kaXlRA=="], "@react-native/community-cli-plugin/metro/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], @@ -4368,10 +4391,6 @@ "connect/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - "expo-modules-autolinking/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - - "expo-modules-autolinking/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "fumadocs-mdx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], @@ -4428,14 +4447,6 @@ "jest-util/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], - "jest-util/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - - "jest-util/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - - "jest-validate/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - - "jest-validate/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "jest-worker/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], "lighthouse-logger/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], diff --git a/packages/expo-kloq/AGENTS.md b/packages/expo-kloq/AGENTS.md new file mode 100644 index 000000000..bc2949d52 --- /dev/null +++ b/packages/expo-kloq/AGENTS.md @@ -0,0 +1,45 @@ +# AGENTS.md — `packages/expo-kloq` (kloq for Expo) + +kloq's calendar for React Native: the same model as `packages/kloq` (store, projection, month/year/agenda +layout, recurrence and its "this / following / all" plans, RRULE presets, quick add, reminders, time +zones, timeline window), drawn with native controls — SwiftUI through `@expo/ui/swift-ui` on iOS (Liquid +Glass on iOS 26+, through `expo-glass-effect`), Jetpack Compose through `@expo/ui/jetpack-compose` on +Android. MIT like kloq. The runnable example is `apps/expo-kloq-example`. + +## Rules + +- **Never copy kloq's logic.** Everything calendar-shaped is imported from kloq's model modules, only + through `src/model.ts` (the one seam), only from `kloq/lib/*` (kloq's root entry pulls in its DOM + components). If something is missing, export it from kloq rather than re-implementing it here. +- kloq's `calendar-store`/`calendar-sources` import `react`: a Metro bundle must resolve that to the + app's React Native React, not the web apps' catalog React — the example's `metro.config.js` pins + `react`, `react-native`, `expo`, `@expo/ui`, `expo-glass-effect` and the safe-area package to the app. + Hosts in other monorepos need the same. +- Native controls live in `src/native/*.ios.tsx` and `*.android.tsx`; the extensionless `*.tsx` next to + them re-exports the iOS file and is only what TypeScript resolves (both platforms implement the props + in `src/native/types.ts`). Import them **extensionless** (`../native/controls`), or Metro's platform + resolution is bypassed. Everything else uses explicit `.ts`/`.tsx` imports. +- `@expo/ui` components render only inside a `Host`; a `Form` needs a sized host + (`style={{ flex: 1 }}` + `useViewportSizeMeasurement`), small controls `matchContents`. +- Containers that hold tappable children (day columns, month cells, timeline lanes) are + `accessible={false}`, or VoiceOver and Maestro see one element and none of the events inside. +- Biome reads `role` on `@expo/ui`'s `Button` as ARIA: `useValidAriaRole` is off here (`biome.jsonc`). + +## Layout + +- `use-kloq.ts`: the controller (kloq's `InternalStore`, the calendar source registry, view + anchor, + save/remove through `editOccurrence`/`deleteOccurrence` + `applyPlan` with an injected scope asker). +- `calendar.tsx`: `` — toolbar (`NavControls`: glass buttons + a SwiftUI `Menu`), the + segmented `ViewSwitcher`, the active view, the floating glass New event / Quick add bar, the sheets. +- `views/`: `TimeGrid` (day, 3 days, week; multi-zone gutter; opens at 8 AM), `MonthView`, `AgendaView`, + `YearView`, `TimelineView` (one lane per visible calendar). Plain React Native views over kloq's layout. +- `sheets/`: native page sheets (`Modal` `pageSheet`) with the actions pinned above a native form — + `EventSheet` (+ the scope dialog), `QuickAddSheet`, `SettingsSheet`. +- `editor/draft.ts`: event ⇄ form state (inclusive all-day ends, custom RRULE builder fields). + +## Checks + +`bun run --cwd packages/expo-kloq typecheck` (TS 7; `noUncheckedIndexedAccess` off because kloq's +sources are type-checked through the imports). There are no unit tests yet: kloq's own model is +tested upstream (`cosscom/calendar`); this package is verified on the Simulator with +`apps/expo-kloq-example/maestro/demo.yaml`. Android is type-checked but not yet run. diff --git a/packages/expo-kloq/CLAUDE.md b/packages/expo-kloq/CLAUDE.md new file mode 100644 index 000000000..43c994c2d --- /dev/null +++ b/packages/expo-kloq/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/packages/expo-kloq/LICENSE b/packages/expo-kloq/LICENSE new file mode 100644 index 000000000..d1fb00342 --- /dev/null +++ b/packages/expo-kloq/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sean Brydon + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/expo-kloq/biome.jsonc b/packages/expo-kloq/biome.jsonc new file mode 100644 index 000000000..bea5d9322 --- /dev/null +++ b/packages/expo-kloq/biome.jsonc @@ -0,0 +1,12 @@ +{ + "root": false, + "$schema": "https://biomejs.dev/schemas/2.5.15/schema.json", + "extends": "//", + "linter": { + "domains": { "react": "recommended" }, + "rules": { + // `role` on @expo/ui's SwiftUI Button is SwiftUI's ButtonRole (destructive, cancel), not ARIA + "a11y": { "useValidAriaRole": "off" } + } + } +} diff --git a/packages/expo-kloq/package.json b/packages/expo-kloq/package.json new file mode 100644 index 000000000..b7d57c401 --- /dev/null +++ b/packages/expo-kloq/package.json @@ -0,0 +1,37 @@ +{ + "name": "expo-kloq", + "version": "0.1.0", + "description": "kloq for Expo: the same calendar model, drawn with native SwiftUI and Jetpack Compose controls.", + "license": "MIT", + "private": true, + "type": "module", + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "kloq": "workspace:*" + }, + "peerDependencies": { + "@expo/ui": "~57.0.25", + "expo": "~57.0.29", + "expo-glass-effect": "~57.0.4", + "react": "19.2.3", + "react-native": "0.86.3", + "react-native-safe-area-context": "~5.7.0" + }, + "devDependencies": { + "@coss/tsconfig": "workspace:*", + "@expo/ui": "57.0.25", + "@types/react": "~19.2.2", + "expo": "~57.0.29", + "expo-glass-effect": "57.0.4", + "react": "19.2.3", + "react-native": "0.86.3", + "react-native-safe-area-context": "~5.7.0", + "typescript": "catalog:" + } +} diff --git a/packages/expo-kloq/src/calendar.tsx b/packages/expo-kloq/src/calendar.tsx new file mode 100644 index 000000000..df6fb2b39 --- /dev/null +++ b/packages/expo-kloq/src/calendar.tsx @@ -0,0 +1,254 @@ +import { GlassContainer, GlassView, isLiquidGlassAvailable } from "expo-glass-effect"; +import { useState } from "react"; +import { Pressable, StyleSheet, Text, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { formatViewTitle, viewKind } from "./model.ts"; +import { NavControls, ViewSwitcher } from "./native/controls"; +import { EventSheet } from "./sheets/event-sheet.tsx"; +import { QuickAddSheet } from "./sheets/quick-add-sheet.tsx"; +import { SettingsSheet } from "./sheets/settings-sheet.tsx"; +import { usePalette } from "./theme.ts"; +import { type EditTarget, type KloqControllerOptions, useKloq } from "./use-kloq.ts"; +import { AgendaView } from "./views/agenda-view.tsx"; +import { MonthView } from "./views/month-view.tsx"; +import { TimeGrid } from "./views/time-grid.tsx"; +import { TimelineView } from "./views/timeline-view.tsx"; +import { YearView } from "./views/year-view.tsx"; + +/** Zones the settings sheet offers for the gutter when the host passes none. */ +const GLASS = isLiquidGlassAvailable(); +const ACCENT = "#0a84ff"; + +export const DEFAULT_ZONE_CHOICES = [ + "America/New_York", + "America/Los_Angeles", + "Europe/London", + "Europe/Berlin", + "Asia/Tokyo", + "Australia/Sydney", +]; + +export interface KloqCalendarProps extends KloqControllerOptions { + /** extra zones drawn in the day/week gutter at first (the user can change them) */ + timeZones?: string[]; + /** zones the settings sheet offers */ + zoneChoices?: string[]; + /** 12-hour clock; defaults to what the device's locale uses */ + hour12?: boolean; +} + +/** + * kloq for React Native: the same store, projection and recurrence rules as ``, drawn with + * native controls (SwiftUI through `@expo/ui` on iOS, Jetpack Compose on Android). + */ +export function KloqCalendar(props: KloqCalendarProps) { + const k = useKloq(props); + const p = usePalette(); + const insets = useSafeAreaInsets(); + const [editing, setEditing] = useState(null); + const [quickAdd, setQuickAdd] = useState(false); + const [settings, setSettings] = useState(false); + const [zones, setZones] = useState(props.timeZones ?? []); + const [hour12, setHour12] = useState(() => props.hour12 ?? localeUses12Hour()); + const kind = viewKind(k.view); + const zoneChoices = props.zoneChoices ?? DEFAULT_ZONE_CHOICES; + + const newEvent = (start?: Date, end?: Date, allDay = false, calendarId?: string) => { + const from = start ?? nextHalfHour(k.anchor); + setEditing({ + kind: "new", + start: from, + end: end ?? new Date(from.getTime() + 60 * 60_000), + allDay, + calendarId, + }); + }; + const callbacks = { + onOpen: setEditing, + onCreate: newEvent, + onShowDay: k.showDay, + }; + const stepWord = + kind === "days" + ? `${k.descriptor.dayCount} days` + : kind === "agenda" + ? "30 days" + : kind === "timeline" + ? "day" + : kind; + + return ( + + + + + {formatViewTitle(k.descriptor, undefined, { short: kind !== "year" })} + + + k.step(-1)} + onNext={() => k.step(1)} + onToday={k.goToday} + onQuickAdd={() => setQuickAdd(true)} + onSettings={() => setSettings(true)} + onReset={k.reset} + view={k.view} + onView={k.setView} + prevLabel={`Previous ${stepWord}`} + nextLabel={`Next ${stepWord}`} + /> + + + + + + + {kind === "month" ? ( + + ) : kind === "agenda" ? ( + + ) : kind === "year" ? ( + + ) : kind === "timeline" ? ( + + ) : ( + + )} + + + + + newEvent()} + accessibilityRole="button" + accessibilityLabel="New event" + > + + + New event + + + setQuickAdd(true)} + accessibilityRole="button" + accessibilityLabel="Quick add" + > + + Quick add + + “Lunch tomorrow 1pm” + + + + + + + setEditing(null)} + onSave={k.save} + onDelete={k.remove} + /> + setQuickAdd(false)} + onAdd={(event, day) => { + k.add(event); + if (kind === "year" || kind === "timeline") k.setView("week"); + k.setAnchor(day); + }} + /> + setSettings(false)} + calendars={k.sources} + zones={zoneChoices} + selectedZones={zones} + onZones={setZones} + hour12={hour12} + onHour12={setHour12} + /> + + ); +} + +function localeUses12Hour(): boolean { + return new Intl.DateTimeFormat(undefined, { hour: "numeric" }).resolvedOptions().hour12 ?? true; +} + +function nextHalfHour(anchor: Date): Date { + const now = new Date(); + const d = new Date(anchor); + d.setHours(now.getHours(), now.getMinutes() < 30 ? 30 : 60, 0, 0); + return d; +} + +const styles = StyleSheet.create({ + root: { flex: 1 }, + toolbar: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 12, + paddingTop: 4, + paddingBottom: 8, + gap: 8, + }, + titleBox: { flex: 1, minWidth: 0 }, + title: { fontSize: 24, fontWeight: "700" }, + switcher: { paddingHorizontal: 12, paddingBottom: 8 }, + body: { flex: 1 }, + fabRow: { position: "absolute", left: 0, right: 0, alignItems: "center" }, + fabs: { flexDirection: "row", gap: 10 }, + fab: { + flexDirection: "row", + alignItems: "center", + gap: 8, + paddingHorizontal: 18, + paddingVertical: 12, + borderRadius: 24, + overflow: "hidden", + }, + fabText: { fontSize: 15, fontWeight: "600" }, + fabHint: { fontSize: 13 }, +}); diff --git a/packages/expo-kloq/src/editor/draft.ts b/packages/expo-kloq/src/editor/draft.ts new file mode 100644 index 000000000..7e06d0bf1 --- /dev/null +++ b/packages/expo-kloq/src/editor/draft.ts @@ -0,0 +1,174 @@ +import { + addDays, + type CalEventPatch, + type EventColor, + formatInstant, + isDateOnly, + normalizeReminders, + parseInstant, + parseRRule, + type Reminder, + type RRuleEnd, + type RRuleFreq, + type RRuleParts, + type RRuleWeekday, + serializeRRule, + startOfLocalDay, + toDateOnlyString, + untilFromDate, + untilToDate, + weekdayCodeOf, + withEnd, +} from "../model.ts"; +import type { EditTarget } from "../use-kloq.ts"; + +/** The editor's working copy. All-day `end` is the last day, inclusive (the store's is exclusive). */ +export interface EventDraft { + title: string; + allDay: boolean; + start: Date; + end: Date; + calendarId?: string; + color?: EventColor; + location: string; + description: string; + /** `null` = does not repeat */ + rrule: string | null; + reminders: Reminder[]; +} + +const HOUR = 60 * 60_000; + +export function draftFor(target: EditTarget, defaultCalendarId?: string): EventDraft { + if (target.kind === "new") { + return { + title: target.title ?? "", + allDay: target.allDay, + start: target.start, + end: target.allDay ? startOfLocalDay(target.start) : target.end, + calendarId: target.calendarId ?? defaultCalendarId, + location: target.location ?? "", + description: "", + rrule: null, + reminders: [], + }; + } + const { event, occurrence } = target; + const startIso = occurrence?.start ?? event.start; + const endIso = occurrence?.end ?? event.end; + const start = new Date(parseInstant(startIso)); + let end = new Date(parseInstant(endIso)); + if (event.allDay) { + // exclusive end → the last day shown + if (isDateOnly(endIso)) end = addDays(end, -1); + if (end < start) end = start; + } + return { + title: event.title, + allDay: event.allDay === true, + start, + end, + calendarId: event.calendarId, + color: event.color, + location: event.location ?? "", + description: event.description ?? "", + rrule: event.rrule ?? null, + reminders: normalizeReminders(event.reminders), + }; +} + +/** Moving the start drags the end along, keeping the length. */ +export function withStart(draft: EventDraft, start: Date): EventDraft { + const length = draft.end.getTime() - draft.start.getTime(); + return { ...draft, start, end: new Date(start.getTime() + Math.max(0, length)) }; +} + +/** The end never precedes the start. */ +export function withEndDate(draft: EventDraft, end: Date): EventDraft { + if (end.getTime() >= draft.start.getTime()) return { ...draft, end }; + return draft.allDay + ? { ...draft, end: draft.start } + : { ...draft, end: new Date(draft.start.getTime() + HOUR) }; +} + +export function withAllDay(draft: EventDraft, allDay: boolean): EventDraft { + if (allDay === draft.allDay) return draft; + if (allDay) { + return { + ...draft, + allDay, + start: startOfLocalDay(draft.start), + end: startOfLocalDay(draft.end), + }; + } + const start = startOfLocalDay(draft.start); + start.setHours(9); + return { ...draft, allDay, start, end: new Date(start.getTime() + HOUR) }; +} + +/** The store patch for a draft (the whole event, for a new one). */ +export function patchFor(draft: EventDraft): CalEventPatch { + const when = draft.allDay + ? { + allDay: true as const, + start: toDateOnlyString(draft.start), + end: toDateOnlyString(addDays(startOfLocalDay(draft.end), 1)), + } + : { allDay: false as const, start: formatInstant(draft.start), end: formatInstant(draft.end) }; + return { + title: draft.title.trim(), + ...when, + calendarId: draft.calendarId, + location: draft.location.trim() || undefined, + description: draft.description.trim() || undefined, + rrule: draft.rrule ?? undefined, + reminders: normalizeReminders(draft.reminders), + }; +} + +// ── custom recurrence ─────────────────────────────────────────────────────── + +/** The custom builder's fields, read from (and written back to) an RRULE. */ +export interface CustomRule { + freq: RRuleFreq; + interval: number; + byDay: RRuleWeekday[]; + end: RRuleEnd["type"]; + until: Date; + count: number; +} + +function endOf(parts: RRuleParts): RRuleEnd { + if (parts.count !== undefined) return { type: "count", count: parts.count }; + if (parts.until) return { type: "until", until: parts.until }; + return { type: "never" }; +} + +export function customFrom(rrule: string | null, start: Date): CustomRule { + const parts = rrule ? parseRRule(rrule) : null; + const end: RRuleEnd = parts ? endOf(parts) : { type: "never" }; + const days = parts?.byDay?.filter((d) => !d.ordinal).map((d) => d.weekday) ?? []; + return { + freq: parts?.freq ?? "WEEKLY", + interval: parts?.interval ?? 1, + byDay: days.length ? days : [weekdayCodeOf(start)], + end: end.type, + until: end.type === "until" ? untilToDate(end.until) : addDays(start, 30), + count: end.type === "count" ? end.count : 10, + }; +} + +export function ruleFrom(custom: CustomRule, start: Date): string { + let parts: RRuleParts = { freq: custom.freq }; + if (custom.interval > 1) parts.interval = custom.interval; + if (custom.freq === "WEEKLY") { + const days = custom.byDay.length ? custom.byDay : [weekdayCodeOf(start)]; + parts.byDay = days.map((weekday) => ({ weekday })); + } + if (custom.end === "until") { + parts = withEnd(parts, { type: "until", until: untilFromDate(custom.until) }); + } else if (custom.end === "count") { + parts = withEnd(parts, { type: "count", count: Math.max(1, custom.count) }); + } + return serializeRRule(parts); +} diff --git a/packages/expo-kloq/src/index.ts b/packages/expo-kloq/src/index.ts new file mode 100644 index 000000000..4a311491a --- /dev/null +++ b/packages/expo-kloq/src/index.ts @@ -0,0 +1,30 @@ +// expo-kloq — public API + +export { DEFAULT_ZONE_CHOICES, KloqCalendar, type KloqCalendarProps } from "./calendar.tsx"; +export { draftFor, type EventDraft, patchFor } from "./editor/draft.ts"; +// the model is kloq's own, re-exported so hosts need only one import +export type { + CalEvent, + CalEventPatch, + CalendarAccount, + CalendarSource, + EventColor, + KloqView, + OnCommit, + PersistenceAdapter, + Reminder, +} from "./model.ts"; +export { buildSeedEvents } from "./model.ts"; +export { type Palette, paletteFor, usePalette } from "./theme.ts"; +export { + type AskScope, + type EditTarget, + type KloqController, + type KloqControllerOptions, + useKloq, +} from "./use-kloq.ts"; +export { AgendaView } from "./views/agenda-view.tsx"; +export { MonthView } from "./views/month-view.tsx"; +export { TimeGrid } from "./views/time-grid.tsx"; +export { TimelineView } from "./views/timeline-view.tsx"; +export { YearView } from "./views/year-view.tsx"; diff --git a/packages/expo-kloq/src/model.ts b/packages/expo-kloq/src/model.ts new file mode 100644 index 000000000..94f60df95 --- /dev/null +++ b/packages/expo-kloq/src/model.ts @@ -0,0 +1,107 @@ +// The one seam to kloq: every calendar rule (views, projection, layout, recurrence, RRULE presets, +// quick add, reminders, time zones) comes from kloq's own model modules, never a copy. Only +// `kloq/lib/*` is imported: kloq's root entry pulls in its DOM components. + +export { groupAgenda } from "kloq/lib/agenda"; +export { + type CalendarAccount, + type CalendarSource, + defaultCalendarId, + filterVisibleEvents, + findCalendar, + getCalendarSources, + groupByAccount, + isVisible, + registerCalendarAccounts, + registerCalendarSources, + resolveEventColor, + setCalendarVisible, + subscribeCalendarSources, +} from "kloq/lib/calendar-sources"; +export { + buildSeedEvents, + InternalStore, + type KloqStore, + newEventId, + type OnCommit, + type PersistenceAdapter, +} from "kloq/lib/calendar-store"; +export { + addDays, + type CalEvent, + type CalEventPatch, + continuesAfter, + continuesBefore, + DEFAULT_EVENT_COLOR, + type EventColor, + formatInstant, + formatMinutes, + formatRange, + type Instant, + isDateOnly, + type KloqView, + minutesOfDate, + parseInstant, + type Reminder, + resolveView, + startOfLocalDay, + toDateOnlyString, + type ViewDescriptor, + viewKind, + wallMinutesBetween, +} from "kloq/lib/calendar-types"; +export { sameDay } from "kloq/lib/date-math"; +export { eventTitle, fullDate, plural } from "kloq/lib/event-text"; +export type { Lane, LaneAdapter } from "kloq/lib/lane"; +export { layoutMonth } from "kloq/lib/month-grid"; +export { formatViewTitle, stepAnchor, todayAnchor } from "kloq/lib/navigation"; +export { layoutDay } from "kloq/lib/overlap"; +export { type PlacedOccurrence, projectEvents } from "kloq/lib/projection"; +export { parseQuickAdd, type QuickAddResult } from "kloq/lib/quick-add"; +export { quickAddPreview } from "kloq/lib/quick-add-view"; +export { + applyPlan, + deleteOccurrence, + editOccurrence, + type Occurrence, + RECURRENCE_SCOPES, + type RecurrenceScope, +} from "kloq/lib/recurrence"; +export { + addReminder, + DEFAULT_REMINDER, + describeReminder, + normalizeReminders, + REMINDER_PRESETS, + removeReminder, + replaceReminder, +} from "kloq/lib/reminders"; +export { + describeRRule, + endOf, + type PresetId, + parseRRule, + presetIdFor, + presetsFor, + type RRuleEnd, + type RRuleFreq, + type RRuleParts, + type RRuleWeekday, + serializeRRule, + untilFromDate, + untilToDate, + WEEKDAY_CODES, + weekdayCodeOf, + withEnd, +} from "kloq/lib/rrule"; +export { projectTimeline } from "kloq/lib/timeline-projection"; +export { resolveWindow, type TimelineWindow } from "kloq/lib/timeline-window"; +export { + COMMON_ZONES, + hourLabel, + localZone, + wallClockIn, + zoneAbbr, + zoneCityLabel, +} from "kloq/lib/timezone"; +export { buildYearGrid, densityByDay, loadOn, weekdayColumns } from "kloq/lib/year-grid"; diff --git a/packages/expo-kloq/src/native/compose-menu.android.tsx b/packages/expo-kloq/src/native/compose-menu.android.tsx new file mode 100644 index 000000000..977e85439 --- /dev/null +++ b/packages/expo-kloq/src/native/compose-menu.android.tsx @@ -0,0 +1,38 @@ +import { DropdownMenu, DropdownMenuItem, Text, TextButton } from "@expo/ui/jetpack-compose"; +import { useState } from "react"; + +export interface ComposeMenuItem { + key: string; + label: string; + selected?: boolean; + onSelect: () => void; +} + +/** A Material 3 dropdown: a text button that opens a list of items (Compose has no picker menu). */ +export function ComposeMenu({ label, items }: { label: string; items: ComposeMenuItem[] }) { + const [open, setOpen] = useState(false); + return ( + setOpen(false)}> + + setOpen(true)}> + {label} + + + + {items.map((item) => ( + { + setOpen(false); + item.onSelect(); + }} + > + + {item.selected ? `✓ ${item.label}` : item.label} + + + ))} + + + ); +} diff --git a/packages/expo-kloq/src/native/controls.android.tsx b/packages/expo-kloq/src/native/controls.android.tsx new file mode 100644 index 000000000..7e14f02e1 --- /dev/null +++ b/packages/expo-kloq/src/native/controls.android.tsx @@ -0,0 +1,90 @@ +import { + FilledTonalButton, + Host, + OutlinedButton, + Row, + SegmentedButton, + SingleChoiceSegmentedButtonRow, + Text, + TextButton, +} from "@expo/ui/jetpack-compose"; +import { StyleSheet } from "react-native"; +import { ComposeMenu } from "./compose-menu.android.tsx"; +import { + type NavControlsProps, + SEGMENT_TAGS, + type SheetButtonProps, + tagOfView, + VIEW_OPTIONS, + type ViewSwitcherProps, + viewOfTag, +} from "./types.ts"; + +/** Previous / Today / Next, New and the overflow menu as Material 3 buttons. */ +export function NavControls(props: NavControlsProps) { + const current = tagOfView(props.view); + return ( + + + + ‹ + + + Today + + + › + + ({ + key: `view-${o.tag}`, + label: o.label, + selected: o.tag === current, + onSelect: () => props.onView(o.key), + })), + { key: "settings", label: "Calendars & time zones", onSelect: props.onSettings }, + { key: "reset", label: "Reset events", onSelect: props.onReset }, + ]} + /> + + + ); +} + +/** Day · Week · Month · Year · Schedule as Material 3 segmented buttons. */ +export function ViewSwitcher({ view, onView }: ViewSwitcherProps) { + const current = tagOfView(view); + return ( + + + {SEGMENT_TAGS.map((t) => ( + onView(viewOfTag(t))}> + + {VIEW_OPTIONS.find((o) => o.tag === t)?.label ?? t} + + + ))} + + + ); +} + +/** A sheet's Cancel / Save button. */ +export function SheetButton({ label, onPress, prominent, disabled }: SheetButtonProps) { + const Component = prominent ? FilledTonalButton : TextButton; + return ( + + + {label} + + + ); +} + +const styles = StyleSheet.create({ + host: { flexShrink: 0 }, + switcher: { alignSelf: "stretch" }, +}); diff --git a/packages/expo-kloq/src/native/controls.ios.tsx b/packages/expo-kloq/src/native/controls.ios.tsx new file mode 100644 index 000000000..458769dfb --- /dev/null +++ b/packages/expo-kloq/src/native/controls.ios.tsx @@ -0,0 +1,159 @@ +import { + Button, + Divider, + GlassEffectContainer, + Host, + HStack, + Menu, + Picker, + Section, + Text, +} from "@expo/ui/swift-ui"; +import { + accessibilityIdentifier, + accessibilityLabel, + buttonStyle, + controlSize, + disabled as disabledModifier, + labelStyle, + pickerStyle, + tag, +} from "@expo/ui/swift-ui/modifiers"; +import { isLiquidGlassAvailable } from "expo-glass-effect"; +import { StyleSheet } from "react-native"; +import { + type NavControlsProps, + SEGMENT_TAGS, + type SheetButtonProps, + tagOfView, + VIEW_OPTIONS, + type ViewSwitcherProps, + viewOfTag, +} from "./types.ts"; + +// Liquid Glass on iOS 26+, the bordered style before it. +const GLASS = isLiquidGlassAvailable(); +const iconButton = [ + buttonStyle(GLASS ? "glass" : "bordered"), + labelStyle("iconOnly"), + controlSize("regular"), +]; + +/** Previous / Today / Next, New and the overflow menu: SwiftUI buttons in one glass container. */ +export function NavControls(props: NavControlsProps) { + return ( + + + +