Changes to xtablo app
This commit is contained in:
parent
07d61421b3
commit
9aa4953ae5
37 changed files with 8665 additions and 2865 deletions
687
docs/superpowers/plans/2026-04-16-backend-cloudflare-worker.md
Normal file
687
docs/superpowers/plans/2026-04-16-backend-cloudflare-worker.md
Normal file
|
|
@ -0,0 +1,687 @@
|
|||
# Backend Cloudflare Worker Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build a new Cloudflare Worker backend in `apps/backend` that preserves the current `apps/api` contract and deploys to `api.xtablo.com` and `api-staging.xtablo.com`.
|
||||
|
||||
**Architecture:** Create a new Worker-native Hono app with request-scoped services for Supabase, Stripe, mail, storage, and billing sync. Port the current router contract in slices, keeping `apps/api` in the repo as the reference implementation while replacing Node-only concerns with Cloudflare-compatible adapters.
|
||||
|
||||
**Tech Stack:** Cloudflare Workers, Hono, TypeScript, Vitest, Wrangler, Supabase JS, Stripe SDK, pnpm workspaces, Turborepo.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-16-backend-cloudflare-worker-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### New files
|
||||
|
||||
**App scaffold**
|
||||
- `apps/backend/package.json` — Worker package scripts and dependencies
|
||||
- `apps/backend/tsconfig.json` — TypeScript config for the backend app
|
||||
- `apps/backend/turbo.json` — task definitions for build, dev, test, lint, typecheck
|
||||
- `apps/backend/wrangler.toml` — Worker config, routes, bindings, environments
|
||||
- `apps/backend/worker/index.ts` — Worker entrypoint exporting `fetch`
|
||||
- `apps/backend/worker-configuration.d.ts` — generated Worker binding types
|
||||
- `apps/backend/vitest.config.ts` — backend test runner config
|
||||
|
||||
**Core app**
|
||||
- `apps/backend/src/create-app.ts` — Hono app factory
|
||||
- `apps/backend/src/env.ts` — typed Worker bindings and runtime env helpers
|
||||
- `apps/backend/src/config/index.ts` — config normalization and validation
|
||||
- `apps/backend/src/types/app.types.ts` — backend Hono env/context types
|
||||
- `apps/backend/src/middlewares/context.ts` — request-scoped service injection
|
||||
- `apps/backend/src/middlewares/auth.ts` — auth, maybe-auth, and access guards
|
||||
|
||||
**Services**
|
||||
- `apps/backend/src/services/supabase.ts`
|
||||
- `apps/backend/src/services/stripe.ts`
|
||||
- `apps/backend/src/services/billing-sync.ts`
|
||||
- `apps/backend/src/services/mail.ts`
|
||||
- `apps/backend/src/services/storage.ts`
|
||||
- `apps/backend/src/services/org-icons.ts`
|
||||
|
||||
**Libraries and routes**
|
||||
- `apps/backend/src/lib/auth.ts`
|
||||
- `apps/backend/src/lib/billing.ts`
|
||||
- `apps/backend/src/lib/helpers.ts`
|
||||
- `apps/backend/src/lib/org-icons.ts`
|
||||
- `apps/backend/src/lib/slots.ts`
|
||||
- `apps/backend/src/lib/token.ts`
|
||||
- `apps/backend/src/routers/index.ts`
|
||||
- `apps/backend/src/routers/public.ts`
|
||||
- `apps/backend/src/routers/invite.ts`
|
||||
- `apps/backend/src/routers/user.ts`
|
||||
- `apps/backend/src/routers/tablo.ts`
|
||||
- `apps/backend/src/routers/tablo_data.ts`
|
||||
- `apps/backend/src/routers/notes.ts`
|
||||
- `apps/backend/src/routers/tasks.ts`
|
||||
- `apps/backend/src/routers/clientInvites.ts`
|
||||
- `apps/backend/src/routers/stripe.ts`
|
||||
- `apps/backend/src/routers/authRouter.ts`
|
||||
- `apps/backend/src/routers/maybeAuthRouter.ts`
|
||||
|
||||
**Tests**
|
||||
- `apps/backend/src/__tests__/create-app.test.ts`
|
||||
- `apps/backend/src/__tests__/config/config.test.ts`
|
||||
- `apps/backend/src/__tests__/auth/auth.test.ts`
|
||||
- `apps/backend/src/__tests__/middlewares/middlewares.test.ts`
|
||||
- `apps/backend/src/__tests__/routes/public.test.ts`
|
||||
- `apps/backend/src/__tests__/routes/user.test.ts`
|
||||
- `apps/backend/src/__tests__/routes/tablo.test.ts`
|
||||
- `apps/backend/src/__tests__/routes/tablo_data.test.ts`
|
||||
- `apps/backend/src/__tests__/routes/clientInvites.test.ts`
|
||||
- `apps/backend/src/__tests__/routes/stripe.test.ts`
|
||||
- `apps/backend/src/__tests__/helpers/*.test.ts`
|
||||
|
||||
### Modified files
|
||||
|
||||
- `package.json` — add `dev:backend`, `test:backend`, and deploy scripts
|
||||
- `pnpm-lock.yaml` — workspace dependency lock updates
|
||||
- `docs/superpowers/specs/2026-04-16-backend-cloudflare-worker-design.md` — only if implementation reveals required spec corrections
|
||||
|
||||
### Existing files used as source material
|
||||
|
||||
- `apps/api/src/config.ts`
|
||||
- `apps/api/src/secrets.ts`
|
||||
- `apps/api/src/middlewares/middleware.ts`
|
||||
- `apps/api/src/helpers/*`
|
||||
- `apps/api/src/routers/*`
|
||||
- `apps/api/src/__tests__/*`
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Scaffold The Worker App
|
||||
|
||||
### Task 1: Create `apps/backend` package, Worker config, and smoke test
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/backend/package.json`
|
||||
- Create: `apps/backend/tsconfig.json`
|
||||
- Create: `apps/backend/turbo.json`
|
||||
- Create: `apps/backend/wrangler.toml`
|
||||
- Create: `apps/backend/worker/index.ts`
|
||||
- Create: `apps/backend/vitest.config.ts`
|
||||
- Create: `apps/backend/src/create-app.ts`
|
||||
- Create: `apps/backend/src/__tests__/create-app.test.ts`
|
||||
- Modify: `package.json`
|
||||
|
||||
- [ ] **Step 1: Write the failing smoke test**
|
||||
|
||||
Create `apps/backend/src/__tests__/create-app.test.ts` with a minimal contract check:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createApp } from "../create-app";
|
||||
|
||||
describe("createApp", () => {
|
||||
it("mounts the API under /api/v1", async () => {
|
||||
const app = createApp({} as never);
|
||||
const res = await app.request("http://example.com/api/v1/unknown");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `pnpm --filter @xtablo/backend test -- --run src/__tests__/create-app.test.ts`
|
||||
|
||||
Expected: FAIL with missing package or missing `createApp` export.
|
||||
|
||||
- [ ] **Step 3: Add the backend package scaffold**
|
||||
|
||||
Create the package and scripts using the same repo conventions as `apps/chat-worker` and `apps/main`.
|
||||
|
||||
Minimum scripts:
|
||||
|
||||
```json
|
||||
{
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"cf-typegen": "wrangler types"
|
||||
}
|
||||
```
|
||||
|
||||
Root script additions:
|
||||
|
||||
```json
|
||||
{
|
||||
"dev:backend": "turbo dev --filter=@xtablo/backend",
|
||||
"test:backend": "turbo test --filter=@xtablo/backend"
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Implement the minimal app factory and Worker entrypoint**
|
||||
|
||||
Start with:
|
||||
|
||||
```ts
|
||||
import { Hono } from "hono";
|
||||
|
||||
export function createApp(_env: unknown) {
|
||||
const app = new Hono();
|
||||
app.basePath("/api/v1");
|
||||
return app;
|
||||
}
|
||||
```
|
||||
|
||||
And in `worker/index.ts`:
|
||||
|
||||
```ts
|
||||
import { createApp } from "../src/create-app";
|
||||
|
||||
export default {
|
||||
fetch(request: Request, env: unknown, ctx: ExecutionContext) {
|
||||
return createApp(env).fetch(request, env, ctx);
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the smoke test, typecheck, and generate Worker types**
|
||||
|
||||
Run:
|
||||
- `pnpm --filter @xtablo/backend test -- --run src/__tests__/create-app.test.ts`
|
||||
- `pnpm --filter @xtablo/backend typecheck`
|
||||
- `pnpm --filter @xtablo/backend cf-typegen`
|
||||
|
||||
Expected: PASS for the smoke test, no type errors, generated `worker-configuration.d.ts`.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add package.json apps/backend
|
||||
git commit -m "feat(backend): scaffold cloudflare worker app"
|
||||
```
|
||||
|
||||
### Task 2: Add env/config validation and request-scoped service context
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/backend/src/env.ts`
|
||||
- Create: `apps/backend/src/config/index.ts`
|
||||
- Create: `apps/backend/src/types/app.types.ts`
|
||||
- Create: `apps/backend/src/middlewares/context.ts`
|
||||
- Create: `apps/backend/src/__tests__/config/config.test.ts`
|
||||
- Modify: `apps/backend/src/create-app.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing config tests**
|
||||
|
||||
Create `apps/backend/src/__tests__/config/config.test.ts` with two checks:
|
||||
|
||||
```ts
|
||||
it("throws when SUPABASE_URL is missing", () => {
|
||||
expect(() => createConfig({ SUPABASE_URL: "" } as never)).toThrow(/SUPABASE_URL/);
|
||||
});
|
||||
|
||||
it("normalizes staging config", () => {
|
||||
const config = createConfig({
|
||||
APP_ENV: "staging",
|
||||
SUPABASE_URL: "https://example.supabase.co",
|
||||
SUPABASE_SERVICE_ROLE_KEY: "srk",
|
||||
} as never);
|
||||
|
||||
expect(config.APP_ENV).toBe("staging");
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the config tests to verify they fail**
|
||||
|
||||
Run: `pnpm --filter @xtablo/backend test -- --run src/__tests__/config/config.test.ts`
|
||||
|
||||
Expected: FAIL because `createConfig` does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement typed bindings and config normalization**
|
||||
|
||||
Define a Worker env type that includes:
|
||||
- `APP_ENV`
|
||||
- `SUPABASE_URL`
|
||||
- `SUPABASE_SERVICE_ROLE_KEY`
|
||||
- `STRIPE_SECRET_KEY`
|
||||
- `STRIPE_WEBHOOK_SECRET`
|
||||
- `STRIPE_SOLO_PRICE_ID`
|
||||
- `STRIPE_TEAM_PRICE_ID`
|
||||
- `STRIPE_FOUNDER_PRICE_ID`
|
||||
- `XTABLO_URL`
|
||||
- `CLIENTS_URL`
|
||||
- `TASKS_SECRET`
|
||||
- email provider credentials
|
||||
- storage configuration
|
||||
|
||||
And validate them in `createConfig()`.
|
||||
|
||||
- [ ] **Step 4: Add request context middleware**
|
||||
|
||||
Create a context middleware that:
|
||||
- builds config from env
|
||||
- constructs service handles lazily
|
||||
- stores them on Hono context
|
||||
|
||||
Keep the surface small:
|
||||
|
||||
```ts
|
||||
type AppServices = {
|
||||
config: AppConfig;
|
||||
supabase: SupabaseClient;
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Re-run config tests and app smoke test**
|
||||
|
||||
Run:
|
||||
- `pnpm --filter @xtablo/backend test -- --run src/__tests__/config/config.test.ts`
|
||||
- `pnpm --filter @xtablo/backend test -- --run src/__tests__/create-app.test.ts`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/backend/src/env.ts apps/backend/src/config apps/backend/src/types apps/backend/src/middlewares/context.ts apps/backend/src/__tests__/config/config.test.ts apps/backend/src/create-app.ts
|
||||
git commit -m "feat(backend): add env validation and request context"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: Port The HTTP Surface
|
||||
|
||||
### Task 3: Port pure libraries and auth middleware before moving routers
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/backend/src/lib/auth.ts`
|
||||
- Create: `apps/backend/src/lib/token.ts`
|
||||
- Create: `apps/backend/src/lib/slots.ts`
|
||||
- Create: `apps/backend/src/lib/billing.ts`
|
||||
- Create: `apps/backend/src/lib/helpers.ts`
|
||||
- Create: `apps/backend/src/middlewares/auth.ts`
|
||||
- Create: `apps/backend/src/__tests__/auth/auth.test.ts`
|
||||
- Create: `apps/backend/src/__tests__/middlewares/middlewares.test.ts`
|
||||
- Create: `apps/backend/src/__tests__/helpers/slots.test.ts`
|
||||
- Use as source: `apps/api/src/helpers/auth.ts`, `apps/api/src/helpers/token.ts`, `apps/api/src/helpers/slots.ts`, `apps/api/src/helpers/billing.ts`, `apps/api/src/helpers/helpers.ts`, `apps/api/src/middlewares/middleware.ts`
|
||||
|
||||
- [ ] **Step 1: Copy the pure helpers and write failing auth tests first**
|
||||
|
||||
Start with `auth.test.ts` assertions that verify:
|
||||
- Bearer token auth succeeds for valid Supabase user
|
||||
- missing bearer token returns 401
|
||||
- maybe-auth leaves `user` as `null`
|
||||
|
||||
Use a stubbed Supabase client, not the real network.
|
||||
|
||||
- [ ] **Step 2: Run the targeted auth and helper tests**
|
||||
|
||||
Run:
|
||||
- `pnpm --filter @xtablo/backend test -- --run src/__tests__/auth/auth.test.ts`
|
||||
- `pnpm --filter @xtablo/backend test -- --run src/__tests__/helpers/slots.test.ts`
|
||||
|
||||
Expected: FAIL.
|
||||
|
||||
- [ ] **Step 3: Port the pure helpers with minimal changes**
|
||||
|
||||
Keep the signatures stable. Only remove direct `process.env` reads where required by the Worker app and pass config in explicitly instead.
|
||||
|
||||
- [ ] **Step 4: Implement Worker auth middleware**
|
||||
|
||||
Split the old `MiddlewareManager` behavior into focused middleware:
|
||||
- `withSupabase`
|
||||
- `requireAuth`
|
||||
- `withMaybeAuth`
|
||||
- `requireRegularUser`
|
||||
- `requireBillingCheckoutAccess`
|
||||
- `requireActivePlanAccess`
|
||||
- `requireTaskBasicAuth`
|
||||
|
||||
- [ ] **Step 5: Re-run targeted tests**
|
||||
|
||||
Run:
|
||||
- `pnpm --filter @xtablo/backend test -- --run src/__tests__/auth/auth.test.ts`
|
||||
- `pnpm --filter @xtablo/backend test -- --run src/__tests__/middlewares/middlewares.test.ts`
|
||||
- `pnpm --filter @xtablo/backend test -- --run src/__tests__/helpers/slots.test.ts`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/backend/src/lib apps/backend/src/middlewares/auth.ts apps/backend/src/__tests__/auth apps/backend/src/__tests__/middlewares apps/backend/src/__tests__/helpers
|
||||
git commit -m "feat(backend): port shared helpers and auth middleware"
|
||||
```
|
||||
|
||||
### Task 4: Port public and maybe-auth routers
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/backend/src/routers/public.ts`
|
||||
- Create: `apps/backend/src/routers/invite.ts`
|
||||
- Create: `apps/backend/src/routers/maybeAuthRouter.ts`
|
||||
- Create: `apps/backend/src/routers/index.ts`
|
||||
- Create: `apps/backend/src/__tests__/routes/public.test.ts`
|
||||
- Use as source: `apps/api/src/routers/public.ts`, `apps/api/src/routers/invite.ts`, `apps/api/src/routers/maybeAuthRouter.ts`, `apps/api/src/routers/index.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing route tests for public slots, org icon fetch, and booking**
|
||||
|
||||
At minimum, cover:
|
||||
- `GET /api/v1/public/slots/:shortUserId/:standardName`
|
||||
- `GET /api/v1/public/org-icons/:orgId/:filename`
|
||||
- `POST /api/v1/book/...` route currently mounted under maybe-auth
|
||||
|
||||
- [ ] **Step 2: Run the targeted route tests**
|
||||
|
||||
Run: `pnpm --filter @xtablo/backend test -- --run src/__tests__/routes/public.test.ts`
|
||||
|
||||
Expected: FAIL.
|
||||
|
||||
- [ ] **Step 3: Port the routers with context-based dependencies**
|
||||
|
||||
Replace direct middleware singleton calls with imported middleware functions and service access from Hono context.
|
||||
|
||||
- [ ] **Step 4: Wire the routers into `createApp()`**
|
||||
|
||||
Mount `/api/v1` exactly as in `apps/api`, preserving route order:
|
||||
- public routes
|
||||
- tasks/basic-auth routes
|
||||
- Stripe webhook
|
||||
- maybe-auth routes
|
||||
- authenticated routes
|
||||
|
||||
- [ ] **Step 5: Re-run the public route tests**
|
||||
|
||||
Run: `pnpm --filter @xtablo/backend test -- --run src/__tests__/routes/public.test.ts`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/backend/src/routers/public.ts apps/backend/src/routers/invite.ts apps/backend/src/routers/maybeAuthRouter.ts apps/backend/src/routers/index.ts apps/backend/src/__tests__/routes/public.test.ts apps/backend/src/create-app.ts
|
||||
git commit -m "feat(backend): port public and maybe-auth routes"
|
||||
```
|
||||
|
||||
### Task 5: Port authenticated route families in contract-preserving slices
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/backend/src/routers/authRouter.ts`
|
||||
- Create: `apps/backend/src/routers/user.ts`
|
||||
- Create: `apps/backend/src/routers/tablo.ts`
|
||||
- Create: `apps/backend/src/routers/tablo_data.ts`
|
||||
- Create: `apps/backend/src/routers/notes.ts`
|
||||
- Create: `apps/backend/src/routers/tasks.ts`
|
||||
- Create: `apps/backend/src/routers/clientInvites.ts`
|
||||
- Create: `apps/backend/src/__tests__/routes/user.test.ts`
|
||||
- Create: `apps/backend/src/__tests__/routes/tablo.test.ts`
|
||||
- Create: `apps/backend/src/__tests__/routes/tablo_data.test.ts`
|
||||
- Create: `apps/backend/src/__tests__/routes/clientInvites.test.ts`
|
||||
- Use as source: `apps/api/src/routers/*.ts`, `apps/api/src/__tests__/routes/*.test.ts`
|
||||
|
||||
- [ ] **Step 1: Copy the highest-value existing route tests before porting code**
|
||||
|
||||
Port these test files first, adapting imports only:
|
||||
- `user.test.ts`
|
||||
- `tablo.test.ts`
|
||||
- `tablo_data.test.ts`
|
||||
- `clientInvites.test.ts`
|
||||
|
||||
Prefer the current expectations verbatim so the new app is measured against the old contract.
|
||||
|
||||
- [ ] **Step 2: Run the route tests to capture failures**
|
||||
|
||||
Run:
|
||||
- `pnpm --filter @xtablo/backend test -- --run src/__tests__/routes/user.test.ts`
|
||||
- `pnpm --filter @xtablo/backend test -- --run src/__tests__/routes/tablo.test.ts`
|
||||
- `pnpm --filter @xtablo/backend test -- --run src/__tests__/routes/tablo_data.test.ts`
|
||||
- `pnpm --filter @xtablo/backend test -- --run src/__tests__/routes/clientInvites.test.ts`
|
||||
|
||||
Expected: FAIL across missing routers and missing services.
|
||||
|
||||
- [ ] **Step 3: Port one router family at a time**
|
||||
|
||||
Order:
|
||||
1. `user.ts`
|
||||
2. `clientInvites.ts`
|
||||
3. `notes.ts`
|
||||
4. `tasks.ts`
|
||||
5. `tablo.ts`
|
||||
6. `tablo_data.ts`
|
||||
|
||||
Keep each port minimal:
|
||||
- preserve handler shape
|
||||
- move direct env access to config
|
||||
- replace direct transport/storage access with service calls
|
||||
|
||||
- [ ] **Step 4: Re-run targeted tests after each router family**
|
||||
|
||||
Do not batch all routers before checking. After each family:
|
||||
|
||||
```bash
|
||||
pnpm --filter @xtablo/backend test -- --run src/__tests__/routes/<file>.test.ts
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the combined authenticated route suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm --filter @xtablo/backend test -- --run src/__tests__/routes/user.test.ts src/__tests__/routes/tablo.test.ts src/__tests__/routes/tablo_data.test.ts src/__tests__/routes/clientInvites.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/backend/src/routers apps/backend/src/__tests__/routes
|
||||
git commit -m "feat(backend): port authenticated route families"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: Replace Node-Only Runtime Dependencies
|
||||
|
||||
### Task 6: Implement storage, org icon, and mail adapters
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/backend/src/services/storage.ts`
|
||||
- Create: `apps/backend/src/services/org-icons.ts`
|
||||
- Create: `apps/backend/src/services/mail.ts`
|
||||
- Create: `apps/backend/src/lib/org-icons.ts`
|
||||
- Modify: `apps/backend/src/routers/public.ts`
|
||||
- Modify: `apps/backend/src/routers/user.ts`
|
||||
- Modify: `apps/backend/src/routers/tablo_data.ts`
|
||||
- Create: `apps/backend/src/__tests__/routes/org-icons.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests around storage-backed routes**
|
||||
|
||||
Add tests that verify:
|
||||
- org icon fetch returns the same content-type and fallback behavior
|
||||
- avatar upload/delete preserves status codes
|
||||
- tablo file get/post/delete preserves route behavior
|
||||
|
||||
- [ ] **Step 2: Run the targeted tests**
|
||||
|
||||
Run:
|
||||
- `pnpm --filter @xtablo/backend test -- --run src/__tests__/routes/org-icons.test.ts`
|
||||
- `pnpm --filter @xtablo/backend test -- --run src/__tests__/routes/tablo_data.test.ts`
|
||||
|
||||
Expected: FAIL.
|
||||
|
||||
- [ ] **Step 3: Implement the storage service interface**
|
||||
|
||||
Start with:
|
||||
|
||||
```ts
|
||||
export interface StorageService {
|
||||
get(key: string, bucket: "web-assets" | "tablo-data"): Promise<StoredObject | null>;
|
||||
put(input: PutStoredObjectInput): Promise<void>;
|
||||
head(key: string, bucket: "web-assets" | "tablo-data"): Promise<StoredObjectMeta | null>;
|
||||
delete(key: string, bucket: "web-assets" | "tablo-data"): Promise<void>;
|
||||
list(prefix: string, bucket: "web-assets" | "tablo-data"): Promise<string[]>;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Implement Worker-compatible org icon and mail services**
|
||||
|
||||
Requirements:
|
||||
- org icon service owns validation, resizing, upload, and fetch
|
||||
- mail service exposes `sendMail({ to, subject, text, html })`
|
||||
- routers do not import AWS SDK, `sharp`, or `nodemailer` directly
|
||||
|
||||
- [ ] **Step 5: Re-run storage and org-icon tests**
|
||||
|
||||
Run:
|
||||
- `pnpm --filter @xtablo/backend test -- --run src/__tests__/routes/org-icons.test.ts`
|
||||
- `pnpm --filter @xtablo/backend test -- --run src/__tests__/routes/user.test.ts`
|
||||
- `pnpm --filter @xtablo/backend test -- --run src/__tests__/routes/tablo_data.test.ts`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/backend/src/services/storage.ts apps/backend/src/services/org-icons.ts apps/backend/src/services/mail.ts apps/backend/src/lib/org-icons.ts apps/backend/src/routers/public.ts apps/backend/src/routers/user.ts apps/backend/src/routers/tablo_data.ts apps/backend/src/__tests__/routes/org-icons.test.ts
|
||||
git commit -m "feat(backend): replace storage, mail, and icon adapters"
|
||||
```
|
||||
|
||||
### Task 7: Replace Stripe sync engine with Worker-side billing sync
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/backend/src/services/stripe.ts`
|
||||
- Create: `apps/backend/src/services/billing-sync.ts`
|
||||
- Modify: `apps/backend/src/routers/stripe.ts`
|
||||
- Create: `apps/backend/src/__tests__/routes/stripe.test.ts`
|
||||
- Use as source: `apps/api/src/routers/stripe.ts`, `apps/api/src/middlewares/stripeSync.ts`
|
||||
|
||||
- [ ] **Step 1: Port the current Stripe tests first**
|
||||
|
||||
Copy and adapt `apps/api/src/__tests__/routes/stripe.test.ts` into the backend test tree.
|
||||
|
||||
- [ ] **Step 2: Run the Stripe route tests**
|
||||
|
||||
Run: `pnpm --filter @xtablo/backend test -- --run src/__tests__/routes/stripe.test.ts`
|
||||
|
||||
Expected: FAIL.
|
||||
|
||||
- [ ] **Step 3: Implement the Stripe service and billing sync service**
|
||||
|
||||
The Stripe service should own:
|
||||
- SDK construction
|
||||
- webhook signature verification
|
||||
- checkout-session helpers
|
||||
|
||||
The billing sync service should own:
|
||||
- event parsing after verification
|
||||
- Supabase writes required by current billing reads
|
||||
- idempotent handling for repeated webhook delivery
|
||||
|
||||
- [ ] **Step 4: Update the Stripe router to use those services**
|
||||
|
||||
Preserve:
|
||||
- route paths
|
||||
- request body expectations
|
||||
- status codes
|
||||
- checkout behavior visible to clients
|
||||
|
||||
- [ ] **Step 5: Re-run Stripe tests and a broader route sweep**
|
||||
|
||||
Run:
|
||||
- `pnpm --filter @xtablo/backend test -- --run src/__tests__/routes/stripe.test.ts`
|
||||
- `pnpm --filter @xtablo/backend test -- --run src/__tests__/routes/user.test.ts src/__tests__/routes/tablo.test.ts`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/backend/src/services/stripe.ts apps/backend/src/services/billing-sync.ts apps/backend/src/routers/stripe.ts apps/backend/src/__tests__/routes/stripe.test.ts
|
||||
git commit -m "feat(backend): replace stripe sync engine with worker billing sync"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 4: Verify Parity And Deployment Readiness
|
||||
|
||||
### Task 8: Add deploy scripts, environment routes, and full verification suite
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/backend/wrangler.toml`
|
||||
- Modify: `package.json`
|
||||
- Modify: `apps/backend/package.json`
|
||||
- Create: `docs/superpowers/reports/2026-04-16-backend-cloudflare-worker-verification.md`
|
||||
|
||||
- [ ] **Step 1: Finalize Wrangler environments and root scripts**
|
||||
|
||||
Ensure `wrangler.toml` has:
|
||||
- base Worker name
|
||||
- `env.staging` route for `api-staging.xtablo.com`
|
||||
- `env.production` route for `api.xtablo.com`
|
||||
- required vars placeholders
|
||||
|
||||
And root scripts for:
|
||||
- `dev:backend`
|
||||
- `test:backend`
|
||||
- `deploy:backend:staging`
|
||||
- `deploy:backend:prod`
|
||||
|
||||
- [ ] **Step 2: Run the backend test suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm --filter @xtablo/backend test
|
||||
pnpm --filter @xtablo/backend typecheck
|
||||
turbo lint --filter=@xtablo/backend
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Run focused regression checks against the old API contract**
|
||||
|
||||
Run the legacy API tests for the same route families where practical and compare expectations:
|
||||
|
||||
```bash
|
||||
pnpm --filter @xtablo/api test -- --run src/__tests__/routes/public.test.ts
|
||||
pnpm --filter @xtablo/api test -- --run src/__tests__/routes/user.test.ts
|
||||
pnpm --filter @xtablo/api test -- --run src/__tests__/routes/stripe.test.ts
|
||||
```
|
||||
|
||||
Expected: the backend assertions match the practical old contract.
|
||||
|
||||
- [ ] **Step 4: Write the manual parity checklist report**
|
||||
|
||||
Record manual checks for:
|
||||
- auth/login-dependent flows
|
||||
- public slots and booking
|
||||
- tablo read/write flows
|
||||
- file upload/download/delete
|
||||
- avatar upload/delete
|
||||
- client invites
|
||||
- checkout session creation
|
||||
- Stripe webhook handling in staging
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add package.json apps/backend/wrangler.toml apps/backend/package.json docs/superpowers/reports/2026-04-16-backend-cloudflare-worker-verification.md
|
||||
git commit -m "chore(backend): finalize deploy config and verification"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes For The Implementer
|
||||
|
||||
- Keep `apps/api` untouched unless a shared helper absolutely must move.
|
||||
- Prefer copying code into `apps/backend` first, then simplifying behind service interfaces once tests are in place.
|
||||
- Do not port `apps/api/src/index.ts` or `apps/api/src/secrets.ts` directly. Those files are reference material only.
|
||||
- Avoid direct `process.env` reads in route files. Resolve config once from Worker env and pass it through context.
|
||||
- Avoid direct imports of `nodemailer`, `sharp`, or `@supabase/stripe-sync-engine` anywhere in `apps/backend`.
|
||||
- Preserve route ordering from `apps/api/src/routers/index.ts`; some behavior depends on middleware order.
|
||||
- Keep commits small and scoped to one task whenever possible.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- `apps/backend` exists as a deployable Cloudflare Worker app.
|
||||
- `apps/backend` serves the current `/api/v1` contract.
|
||||
- the backend test suite passes.
|
||||
- staging and production routes are configured.
|
||||
- a manual parity report exists for web app validation against `api-staging.xtablo.com` and `api.xtablo.com`.
|
||||
|
||||
Plan complete and saved to `docs/superpowers/plans/2026-04-16-backend-cloudflare-worker.md`. Ready to execute?
|
||||
165
docs/superpowers/plans/2026-04-19-expo-chat-avatars.md
Normal file
165
docs/superpowers/plans/2026-04-19-expo-chat-avatars.md
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
# Expo Chat Avatars Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Show participant avatars in the Expo chat timeline for both incoming and outgoing message groups with a clean initial fallback.
|
||||
|
||||
**Architecture:** Keep avatar data sourced from `useTabloMembers(channelId)` and the authenticated user already available in Expo state. Extract sender display resolution into a pure helper with tests, then update the channel screen to render remote images when present and initial badges when the URL is missing or fails to load.
|
||||
|
||||
**Tech Stack:** Expo SDK 54, React Native, Expo Router, TypeScript, Jest Expo, ESLint Expo.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-19-expo-chat-avatars-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### New files
|
||||
- `xtablo-expo/features/chat/messageAuthor.ts` — pure sender display resolution helper for chat message groups
|
||||
- `xtablo-expo/features/chat/messageAuthor.test.ts` — helper tests for avatar and name fallback behavior
|
||||
|
||||
### Modified files
|
||||
- `xtablo-expo/app/(app)/channel/[cid].tsx` — render avatars for both sides and handle image fallback
|
||||
|
||||
### Existing files used as references
|
||||
- `xtablo-expo/hooks/members.ts`
|
||||
- `xtablo-expo/stores/auth.tsx`
|
||||
- `xtablo-expo/app/(app)/user/index.tsx`
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Add Sender Display Helper
|
||||
|
||||
### Task 1: Create a pure helper for author display data
|
||||
|
||||
**Files:**
|
||||
- Create: `xtablo-expo/features/chat/messageAuthor.ts`
|
||||
- Create: `xtablo-expo/features/chat/messageAuthor.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add focused tests for:
|
||||
- member data lookup by `userId`
|
||||
- current-user fallback when member data is missing
|
||||
- avatar suppression after a tracked image failure
|
||||
- safe initial/name fallback when sender data is incomplete
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd xtablo-expo && npx jest --runInBand --watchman=false features/chat/messageAuthor.test.ts
|
||||
```
|
||||
|
||||
Expected: FAIL because the helper file does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Write the minimal helper**
|
||||
|
||||
Implement a pure helper that returns:
|
||||
|
||||
```ts
|
||||
type MessageAuthorDisplay = {
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
initial: string;
|
||||
};
|
||||
```
|
||||
|
||||
Rules:
|
||||
- prefer member data from the current channel
|
||||
- for the authenticated user, fall back to Expo auth user data
|
||||
- if the avatar is marked failed for that user id, return `null`
|
||||
- derive a stable initial with a default fallback of `U`
|
||||
|
||||
- [ ] **Step 4: Run the helper test to verify it passes**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd xtablo-expo && npx jest --runInBand --watchman=false features/chat/messageAuthor.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: Integrate Avatars Into The Channel Screen
|
||||
|
||||
### Task 2: Render message-group avatars on both sides
|
||||
|
||||
**Files:**
|
||||
- Modify: `xtablo-expo/app/(app)/channel/[cid].tsx`
|
||||
- Use: `xtablo-expo/features/chat/messageAuthor.ts`
|
||||
|
||||
- [ ] **Step 1: Confirm the current screen still uses initial-only avatars**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
rg -n "avatarText|Image|avatar_url" "xtablo-expo/app/(app)/channel/[cid].tsx"
|
||||
```
|
||||
|
||||
Expected: the file contains initial badge styles and member lookups, but no chat-avatar image rendering path.
|
||||
|
||||
- [ ] **Step 2: Update the screen to resolve sender display**
|
||||
|
||||
Add:
|
||||
- authenticated user read from `useAuthStore`
|
||||
- local failed-avatar state keyed by user id
|
||||
- helper-based sender display resolution for each message group
|
||||
|
||||
- [ ] **Step 3: Render remote avatars with fallback**
|
||||
|
||||
Update the sender-row renderer to:
|
||||
- use `Image` when `avatarUrl` exists
|
||||
- fall back to the existing initial badge otherwise
|
||||
- mark the user id as failed on `Image` load error
|
||||
- show the sender row for both incoming and outgoing groups
|
||||
- keep outgoing groups right-aligned
|
||||
|
||||
- [ ] **Step 4: Run verification**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd xtablo-expo && npx jest --runInBand --watchman=false features/chat/messageAuthor.test.ts
|
||||
cd xtablo-expo && npx tsc --noEmit
|
||||
cd xtablo-expo && npm run lint
|
||||
```
|
||||
|
||||
Expected:
|
||||
- helper tests pass
|
||||
- TypeScript passes
|
||||
- lint reports no new errors from the chat screen change
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: Manual Validation
|
||||
|
||||
### Task 3: Check a live chat thread
|
||||
|
||||
**Files:**
|
||||
- No additional file changes required
|
||||
|
||||
- [ ] **Step 1: Run the Expo app**
|
||||
|
||||
Run one of:
|
||||
|
||||
```bash
|
||||
cd xtablo-expo && npm run ios
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```bash
|
||||
cd xtablo-expo && npx expo start --dev-client
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Validate avatar behavior**
|
||||
|
||||
Manual checks:
|
||||
- participant avatars appear for both incoming and outgoing groups when URLs exist
|
||||
- missing avatars fall back to initials
|
||||
- broken avatar URLs fall back to initials after load failure
|
||||
- sender-row alignment still looks correct on both sides
|
||||
|
|
@ -0,0 +1,276 @@
|
|||
# Expo Chat Day Separator Bars Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add horizontal day separator bars to the Expo chat timeline so multi-day conversations show clear date boundaries.
|
||||
|
||||
**Architecture:** Extract the date-boundary logic into a small pure helper that converts grouped chat messages into timeline items, then update the chat screen to render either a message group or a day-separator row. Keep the existing chat hook, message grouping behavior, pagination, typing indicator, and input UI unchanged.
|
||||
|
||||
**Tech Stack:** Expo SDK 54, React Native, Expo Router, TypeScript, Jest, Expo ESLint.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-19-expo-chat-day-separator-bars-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### New files
|
||||
- `xtablo-expo/app/(app)/channel/channelTimeline.ts` — pure helper types and functions for converting `MessageGroup[]` into `TimelineItem[]`
|
||||
- `xtablo-expo/app/(app)/channel/channelTimeline.test.ts` — unit tests for day-boundary insertion and French date labels
|
||||
|
||||
### Modified files
|
||||
- `xtablo-expo/app/(app)/channel/[cid].tsx` — consume the helper, render separator rows, and add separator styles
|
||||
|
||||
### Existing files used as references
|
||||
- `xtablo-expo/app/(app)/channel/[cid].tsx`
|
||||
- `xtablo-expo/types/chat.types.ts`
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Add a Testable Timeline Helper
|
||||
|
||||
### Task 1: Create pure timeline-item logic with unit tests
|
||||
|
||||
**Files:**
|
||||
- Create: `xtablo-expo/app/(app)/channel/channelTimeline.ts`
|
||||
- Create: `xtablo-expo/app/(app)/channel/channelTimeline.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `xtablo-expo/app/(app)/channel/channelTimeline.test.ts` with focused cases:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from "@jest/globals";
|
||||
import {
|
||||
buildTimelineItems,
|
||||
formatDayLabel,
|
||||
type MessageGroup,
|
||||
} from "./channelTimeline";
|
||||
|
||||
const makeGroup = (key: string, createdAt: string): MessageGroup => ({
|
||||
key,
|
||||
userId: "user-1",
|
||||
isOwn: false,
|
||||
messages: [
|
||||
{
|
||||
id: key,
|
||||
userId: "user-1",
|
||||
text: "hello",
|
||||
createdAt,
|
||||
clientId: key,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe("buildTimelineItems", () => {
|
||||
it("inserts one separator for the first group of a day", () => {
|
||||
const items = buildTimelineItems([
|
||||
makeGroup("a", "2026-04-19T08:00:00.000Z"),
|
||||
makeGroup("b", "2026-04-19T10:00:00.000Z"),
|
||||
], new Date("2026-04-19T12:00:00.000Z"));
|
||||
|
||||
expect(items.filter((item) => item.type === "day-separator")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("inserts a new separator when the day changes", () => {
|
||||
const items = buildTimelineItems([
|
||||
makeGroup("a", "2026-04-18T23:00:00.000Z"),
|
||||
makeGroup("b", "2026-04-19T08:00:00.000Z"),
|
||||
], new Date("2026-04-19T12:00:00.000Z"));
|
||||
|
||||
expect(items.filter((item) => item.type === "day-separator")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDayLabel", () => {
|
||||
it("returns Aujourd’hui for the current day", () => {
|
||||
expect(
|
||||
formatDayLabel(new Date("2026-04-19T08:00:00.000Z"), new Date("2026-04-19T12:00:00.000Z"))
|
||||
).toBe("Aujourd’hui");
|
||||
});
|
||||
|
||||
it("returns Hier for the previous day", () => {
|
||||
expect(
|
||||
formatDayLabel(new Date("2026-04-18T08:00:00.000Z"), new Date("2026-04-19T12:00:00.000Z"))
|
||||
).toBe("Hier");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd xtablo-expo && npx jest --runInBand --watchman=false app/'(app)'/channel/channelTimeline.test.ts
|
||||
```
|
||||
|
||||
Expected: FAIL because `channelTimeline.ts` does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement the minimal helper**
|
||||
|
||||
Create `xtablo-expo/app/(app)/channel/channelTimeline.ts` with:
|
||||
- exported `MessageGroup` type (matching the current screen’s shape)
|
||||
- exported `TimelineItem` union type:
|
||||
- `{ type: "day-separator"; key: string; label: string }`
|
||||
- `{ type: "message-group"; key: string; group: MessageGroup }`
|
||||
- `formatDayLabel(date, now)` helper returning:
|
||||
- `Aujourd’hui`
|
||||
- `Hier`
|
||||
- otherwise a French localized date
|
||||
- `buildTimelineItems(groups, now)` helper that:
|
||||
- walks groups in chronological order
|
||||
- inserts one separator before the first group of each day
|
||||
- returns stable keys such as `day-YYYY-MM-DD`
|
||||
|
||||
Implementation rules:
|
||||
- compare by local calendar day, not raw timestamp equality
|
||||
- derive the day from the first message in each group
|
||||
- keep the helper pure and UI-free
|
||||
|
||||
- [ ] **Step 4: Run the helper test to verify it passes**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd xtablo-expo && npx jest --runInBand --watchman=false app/'(app)'/channel/channelTimeline.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add xtablo-expo/app/'(app)'/channel/channelTimeline.ts xtablo-expo/app/'(app)'/channel/channelTimeline.test.ts
|
||||
git commit -m "feat(expo-chat): add timeline day separator helper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: Integrate Separator Bars into the Chat Screen
|
||||
|
||||
### Task 2: Render horizontal day bars in the chat timeline
|
||||
|
||||
**Files:**
|
||||
- Modify: `xtablo-expo/app/(app)/channel/[cid].tsx`
|
||||
- Use: `xtablo-expo/app/(app)/channel/channelTimeline.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing screen verification**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
rg -n "day-separator|separatorLine|separatorLabel|TimelineItem" 'xtablo-expo/app/(app)/channel/[cid].tsx'
|
||||
```
|
||||
|
||||
Expected: no results, confirming the chat screen does not yet support day separators.
|
||||
|
||||
- [ ] **Step 2: Refactor the screen to consume timeline items**
|
||||
|
||||
Update `xtablo-expo/app/(app)/channel/[cid].tsx` to:
|
||||
- import `buildTimelineItems`, `TimelineItem`, and `MessageGroup` from `channelTimeline.ts`
|
||||
- remove the local `MessageGroup` type if duplicated
|
||||
- replace `messageGroups` list rendering with `timelineItems`
|
||||
- call `buildTimelineItems(groupMessages(messages, currentUserId), new Date())`
|
||||
- reverse timeline items for the inverted `FlatList` after separators are inserted in chronological order
|
||||
|
||||
- [ ] **Step 3: Add the separator renderer**
|
||||
|
||||
Add a `renderTimelineItem` function that:
|
||||
- renders existing message groups through the current bubble UI
|
||||
- renders day separators as a centered row with:
|
||||
- left line segment
|
||||
- centered label
|
||||
- right line segment
|
||||
|
||||
Style requirements:
|
||||
- neutral line color using existing light/dark text or border tones
|
||||
- subdued label text color
|
||||
- enough vertical spacing to visually separate days
|
||||
|
||||
Suggested structure:
|
||||
|
||||
```tsx
|
||||
<View style={styles.daySeparator}>
|
||||
<View style={[styles.daySeparatorLine, { backgroundColor: separatorColor }]} />
|
||||
<Text style={[styles.daySeparatorLabel, { color: subtextColor }]}>{item.label}</Text>
|
||||
<View style={[styles.daySeparatorLine, { backgroundColor: separatorColor }]} />
|
||||
</View>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Keep existing chat behavior unchanged**
|
||||
|
||||
While integrating the separator:
|
||||
- keep pagination via `onEndReached`
|
||||
- keep typing indicator logic untouched
|
||||
- keep message grouping layout untouched
|
||||
- keep input behavior untouched
|
||||
|
||||
- [ ] **Step 5: Run verification**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd xtablo-expo && npx tsc --noEmit
|
||||
cd xtablo-expo && npm run lint
|
||||
cd xtablo-expo && npx jest --runInBand --watchman=false
|
||||
```
|
||||
|
||||
Expected:
|
||||
- TypeScript passes
|
||||
- lint has no new errors from the chat screen change
|
||||
- Jest passes, including `channelTimeline.test.ts`
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add xtablo-expo/app/'(app)'/channel/'[cid]'.tsx
|
||||
git commit -m "feat(expo-chat): add day separator bars to chat timeline"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: Final Manual Validation
|
||||
|
||||
### Task 3: Confirm the UI behavior on device or simulator
|
||||
|
||||
**Files:**
|
||||
- No additional file changes required
|
||||
|
||||
- [ ] **Step 1: Run the Expo app**
|
||||
|
||||
Run one of:
|
||||
|
||||
```bash
|
||||
cd xtablo-expo && npm run ios
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```bash
|
||||
cd xtablo-expo && npx expo start --dev-client
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify a multi-day chat thread**
|
||||
|
||||
Manual checks:
|
||||
- separators appear once per day
|
||||
- separator label is `Aujourd’hui` for today
|
||||
- separator label is `Hier` for yesterday
|
||||
- older days render as French localized dates
|
||||
- message order is still correct in the inverted list
|
||||
|
||||
- [ ] **Step 3: Verify no regression in chat interactions**
|
||||
|
||||
Manual checks:
|
||||
- loading older messages still works
|
||||
- typing indicator still appears
|
||||
- sending a message still works
|
||||
- input remains pinned and usable
|
||||
|
||||
- [ ] **Step 4: Commit if any final UI polish was required**
|
||||
|
||||
```bash
|
||||
git add xtablo-expo
|
||||
git commit -m "chore(expo-chat): polish day separator bars"
|
||||
```
|
||||
163
docs/superpowers/plans/2026-04-19-xtablo-expo-primary-purple.md
Normal file
163
docs/superpowers/plans/2026-04-19-xtablo-expo-primary-purple.md
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
# Expo Primary Purple Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the Expo app's current blue primary accent with the `apps/main` purple brand color and centralize that primary token.
|
||||
|
||||
**Architecture:** First establish a shared Expo brand source in `xtablo-expo/constants/colors.ts`, then migrate direct blue primary usages onto that shared purple or its darker pressed companion. Keep semantic non-primary colors unchanged and treat this as a visual token migration, not a UI redesign.
|
||||
|
||||
**Tech Stack:** Expo SDK 54, React Native, Expo Router, TypeScript, Jest, Expo ESLint.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-19-xtablo-expo-primary-purple-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### Shared color source
|
||||
- Modify: `xtablo-expo/constants/colors.ts` — define the Expo brand purple constants and route shared tint/default-primary values through them
|
||||
|
||||
### Screens and components with current primary-blue accents
|
||||
- Modify: `xtablo-expo/app/(app)/(tabs)/_layout.tsx`
|
||||
- Modify: `xtablo-expo/app/(app)/(tabs)/index.tsx`
|
||||
- Modify: `xtablo-expo/app/(app)/(tabs)/planning.tsx`
|
||||
- Modify: `xtablo-expo/app/(app)/(tabs)/settings.tsx`
|
||||
- Modify: `xtablo-expo/app/(app)/(tabs)/tablos.tsx`
|
||||
- Modify: `xtablo-expo/app/(app)/channel/[cid].tsx`
|
||||
- Modify: `xtablo-expo/app/(app)/tablo/[id].tsx`
|
||||
- Modify: `xtablo-expo/app/(app)/task/[id].tsx`
|
||||
- Modify: `xtablo-expo/app/(app)/user/index.tsx`
|
||||
- Modify: `xtablo-expo/app/login.tsx`
|
||||
- Modify: `xtablo-expo/app/signup.tsx`
|
||||
- Modify: `xtablo-expo/components/tasks/AssigneePicker.tsx`
|
||||
- Modify: `xtablo-expo/components/tasks/EtapePicker.tsx`
|
||||
- Modify: `xtablo-expo/components/tasks/EtapeSheet.tsx`
|
||||
- Modify: `xtablo-expo/components/tasks/TaskList.tsx`
|
||||
- Modify: `xtablo-expo/components/tasks/TaskRow.tsx`
|
||||
- Modify: `xtablo-expo/types/tasks.types.ts`
|
||||
|
||||
### Existing modified files to handle carefully
|
||||
- Respect current in-progress Expo SDK 54 edits already present in `xtablo-expo`
|
||||
- Do not revert unrelated type/test/lint fixes already in the worktree
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Establish Shared Brand Tokens
|
||||
|
||||
### Task 1: Centralize the purple primary in `colors.ts`
|
||||
|
||||
**Files:**
|
||||
- Modify: `xtablo-expo/constants/colors.ts`
|
||||
|
||||
- [ ] **Step 1: Write a failing verification command**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
rg -n "tintColorLight|tabIconSelected|#0a7ea4|#3b82f6" xtablo-expo/constants/colors.ts
|
||||
```
|
||||
|
||||
Expected: output still shows the old shared tint/default-primary values.
|
||||
|
||||
- [ ] **Step 2: Replace the shared blue primary with the `apps/main` purple**
|
||||
|
||||
Update `xtablo-expo/constants/colors.ts` to define and export:
|
||||
- `PRIMARY = "#804EEC"`
|
||||
- `PRIMARY_DARK = "#6f3fd4"`
|
||||
|
||||
Then wire the light-mode tint and selected-tab values to that shared purple source.
|
||||
|
||||
- [ ] **Step 3: Verify the shared color source changed as expected**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
rg -n "#804EEC|#6f3fd4|#0a7ea4" xtablo-expo/constants/colors.ts
|
||||
```
|
||||
|
||||
Expected:
|
||||
- `#804EEC` and `#6f3fd4` are present
|
||||
- `#0a7ea4` is gone
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add xtablo-expo/constants/colors.ts
|
||||
git commit -m "feat(expo): add shared purple brand color"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: Replace App-Wide Primary Blue Usages
|
||||
|
||||
### Task 2: Migrate direct primary-blue accents to the shared purple
|
||||
|
||||
**Files:**
|
||||
- Modify: `xtablo-expo/app/(app)/(tabs)/_layout.tsx`
|
||||
- Modify: `xtablo-expo/app/(app)/(tabs)/index.tsx`
|
||||
- Modify: `xtablo-expo/app/(app)/(tabs)/planning.tsx`
|
||||
- Modify: `xtablo-expo/app/(app)/(tabs)/settings.tsx`
|
||||
- Modify: `xtablo-expo/app/(app)/(tabs)/tablos.tsx`
|
||||
- Modify: `xtablo-expo/app/(app)/channel/[cid].tsx`
|
||||
- Modify: `xtablo-expo/app/(app)/tablo/[id].tsx`
|
||||
- Modify: `xtablo-expo/app/(app)/task/[id].tsx`
|
||||
- Modify: `xtablo-expo/app/(app)/user/index.tsx`
|
||||
- Modify: `xtablo-expo/app/login.tsx`
|
||||
- Modify: `xtablo-expo/app/signup.tsx`
|
||||
- Modify: `xtablo-expo/components/tasks/AssigneePicker.tsx`
|
||||
- Modify: `xtablo-expo/components/tasks/EtapePicker.tsx`
|
||||
- Modify: `xtablo-expo/components/tasks/EtapeSheet.tsx`
|
||||
- Modify: `xtablo-expo/components/tasks/TaskList.tsx`
|
||||
- Modify: `xtablo-expo/components/tasks/TaskRow.tsx`
|
||||
- Modify: `xtablo-expo/types/tasks.types.ts`
|
||||
|
||||
- [ ] **Step 1: Write a failing verification command**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
rg -n "#3b82f6|#60a5fa|#1e3a8a|#007bff" xtablo-expo --glob '!**/node_modules/**'
|
||||
```
|
||||
|
||||
Expected: primary-brand blue usages are still present across the Expo app.
|
||||
|
||||
- [ ] **Step 2: Replace blue primary accents with the purple brand values**
|
||||
|
||||
Apply the following rules:
|
||||
- replace generic primary `#3b82f6` with `#804EEC`
|
||||
- replace darker pressed/hover blue where it is acting as the primary companion with `#6f3fd4`
|
||||
- replace blue gradients used for primary hero/header treatments with purple equivalents derived from the new brand
|
||||
- replace default blue fallbacks with the purple primary
|
||||
- keep non-primary semantic colors unchanged
|
||||
|
||||
- [ ] **Step 3: Verify the direct primary blue usages are gone**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
rg -n "#3b82f6|#60a5fa|#1e3a8a|#007bff" xtablo-expo --glob '!**/node_modules/**'
|
||||
```
|
||||
|
||||
Expected: no remaining results, or only intentionally preserved non-primary cases that are explicitly reviewed.
|
||||
|
||||
- [ ] **Step 4: Run app verification**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd xtablo-expo && npx tsc --noEmit
|
||||
cd xtablo-expo && npm run lint
|
||||
cd xtablo-expo && npx jest --runInBand --watchman=false
|
||||
```
|
||||
|
||||
Expected:
|
||||
- TypeScript passes
|
||||
- lint has no new errors caused by this migration
|
||||
- Jest still passes
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add xtablo-expo
|
||||
git commit -m "feat(expo): align primary accents with web purple brand"
|
||||
```
|
||||
127
docs/superpowers/specs/2026-04-19-expo-chat-avatars-design.md
Normal file
127
docs/superpowers/specs/2026-04-19-expo-chat-avatars-design.md
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
# Expo Chat: Message Avatars Design
|
||||
|
||||
**Date:** 2026-04-19
|
||||
**Scope:** Render participant avatars in the Expo chat screen for both incoming and outgoing message groups by using existing member profile data.
|
||||
|
||||
## Context
|
||||
|
||||
The Expo chat screen at `xtablo-expo/app/(app)/channel/[cid].tsx` already:
|
||||
- groups messages by sender and time
|
||||
- resolves sender names from `useTabloMembers(channelId)`
|
||||
- renders a circular initial badge for other participants
|
||||
- does not render remote avatars
|
||||
- does not show an avatar on outgoing message groups
|
||||
|
||||
Channel membership data already includes `avatar_url`, so the screen has the information needed to attempt avatar loading without changing the chat transport.
|
||||
|
||||
## Goal
|
||||
|
||||
Show a profile avatar for each message group in the channel timeline:
|
||||
- for other participants
|
||||
- for the current user
|
||||
- with an initial-based fallback when no avatar is available or loading fails
|
||||
|
||||
The change should stay within the Expo app and reuse existing data sources.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- changing the chat REST or websocket payloads
|
||||
- adding avatar upload or editing flows
|
||||
- changing message grouping rules
|
||||
- redesigning the bubble layout beyond what is required to display avatars
|
||||
|
||||
## Architecture
|
||||
|
||||
The existing `members` query remains the primary source of sender display information.
|
||||
|
||||
### Sender Display Resolution
|
||||
|
||||
The screen should resolve sender display metadata from:
|
||||
1. `userMap[userId]` from `useTabloMembers(channelId)`
|
||||
2. the authenticated Expo user as a fallback for the current user
|
||||
3. hard fallbacks for missing name/avatar values
|
||||
|
||||
Suggested resolved display shape:
|
||||
|
||||
```ts
|
||||
type MessageAuthorDisplay = {
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
initial: string;
|
||||
};
|
||||
```
|
||||
|
||||
This resolution logic should live in a small pure helper so it can be tested independently of the React Native screen.
|
||||
|
||||
### Avatar Fallback Rule
|
||||
|
||||
Avatar rendering should follow this order:
|
||||
1. render remote image when `avatarUrl` is present and has not failed for that user
|
||||
2. fall back to the existing initial badge when the URL is missing
|
||||
3. fall back to the existing initial badge after `Image` load failure for that user
|
||||
|
||||
The failure state should be tracked locally in the screen so repeated groups from the same sender do not keep retrying a known-bad URL during the same render session.
|
||||
|
||||
## Components
|
||||
|
||||
### New helper
|
||||
|
||||
Add a pure helper in `xtablo-expo/features/chat/` that:
|
||||
- resolves sender name
|
||||
- resolves sender avatar URL
|
||||
- derives the fallback initial
|
||||
|
||||
### Channel screen changes
|
||||
|
||||
Update `xtablo-expo/app/(app)/channel/[cid].tsx` to:
|
||||
- render an avatar for both incoming and outgoing message groups
|
||||
- use `Image` for remote avatars
|
||||
- fall back to the existing colored initial badge
|
||||
- track image failures per user id
|
||||
|
||||
The rest of the screen remains unchanged:
|
||||
- message fetching
|
||||
- message grouping
|
||||
- pagination
|
||||
- typing indicator
|
||||
- input behavior
|
||||
|
||||
## Data Flow
|
||||
|
||||
The screen should process author display data in this order:
|
||||
|
||||
1. read `members` from `useTabloMembers(channelId)`
|
||||
2. build `userMap` keyed by member id
|
||||
3. resolve each message group's author display data from the helper
|
||||
4. render the avatar image when possible
|
||||
5. fall back to initials when the avatar is unavailable or failed
|
||||
|
||||
## Visual Treatment
|
||||
|
||||
The avatar should reuse the current compact chat scale:
|
||||
- circular
|
||||
- 24x24
|
||||
- aligned with the sender row
|
||||
|
||||
For outgoing groups, the sender row should remain right-aligned while still showing the avatar.
|
||||
|
||||
## Error Handling And Verification
|
||||
|
||||
Verification should focus on avatar-source correctness and renderer stability:
|
||||
|
||||
- helper tests cover member lookup, current-user fallback, and failure fallback
|
||||
- `npx tsc --noEmit` in `xtablo-expo` still passes
|
||||
- Expo lint does not report new errors from the chat screen change
|
||||
- manual UI check confirms:
|
||||
- avatars appear for both sides when URLs exist
|
||||
- initials appear when URLs are missing
|
||||
- initials replace avatars after image load failure
|
||||
- message alignment and input behavior remain unchanged
|
||||
|
||||
## Success Criteria
|
||||
|
||||
The feature is complete when:
|
||||
- both incoming and outgoing message groups attempt to render avatars
|
||||
- avatar URLs are sourced from existing member/profile data
|
||||
- broken or missing avatar URLs fall back cleanly to initials
|
||||
- no backend or transport changes are required
|
||||
|
|
@ -4,6 +4,7 @@ import { Platform } from "react-native";
|
|||
|
||||
import { HapticTab } from "@/components/HapticTab";
|
||||
import TabBarBackground from "@/components/ui/TabBarBackground";
|
||||
import { PRIMARY } from "@/constants/colors";
|
||||
import { useColorScheme } from "@/hooks/useColorScheme";
|
||||
import { MessageCircle, Calendar, Grid3X3, Settings } from "lucide-react-native";
|
||||
|
||||
|
|
@ -15,7 +16,7 @@ export default function TabLayout() {
|
|||
<Tabs
|
||||
screenOptions={{
|
||||
// Colors and theming
|
||||
tabBarActiveTintColor: "#3b82f6", // Modern blue color
|
||||
tabBarActiveTintColor: PRIMARY,
|
||||
tabBarInactiveTintColor: isDark ? "#9CA3AF" : "#6B7280",
|
||||
headerShown: false,
|
||||
tabBarButton: HapticTab,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { useThemeColor } from "@/hooks/useThemeColor";
|
|||
import { useColorScheme } from "@/hooks/useColorScheme";
|
||||
import { useTablosList } from "@/hooks/tablos";
|
||||
import { useChatUnread } from "@/hooks/chatUnread";
|
||||
import { ColorMap } from "@/constants/colors";
|
||||
import { ColorMap, PRIMARY } from "@/constants/colors";
|
||||
import { UserTablo } from "@/types/tablos.types";
|
||||
|
||||
export default function DiscussionsTab() {
|
||||
|
|
@ -49,7 +49,7 @@ export default function DiscussionsTab() {
|
|||
|
||||
const renderChannel = ({ item }: { item: UserTablo }) => {
|
||||
const unread = getUnreadCount(item.id);
|
||||
const tabloColor = item.color ? ColorMap[item.color] ?? "#3b82f6" : "#3b82f6";
|
||||
const tabloColor = item.color ? ColorMap[item.color] ?? PRIMARY : PRIMARY;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
|
|
@ -146,7 +146,7 @@ const styles = StyleSheet.create({
|
|||
fontWeight: "700",
|
||||
},
|
||||
unreadBadge: {
|
||||
backgroundColor: "#3b82f6",
|
||||
backgroundColor: PRIMARY,
|
||||
borderRadius: 10,
|
||||
minWidth: 20,
|
||||
height: 20,
|
||||
|
|
|
|||
|
|
@ -30,7 +30,12 @@ import { useEventsByTablo, useCreateEvent } from "@/hooks/events";
|
|||
import { EventAndTablo, EventInsert } from "@/types/events.types";
|
||||
import { useTablosList } from "@/hooks/tablos";
|
||||
import { UserTablo } from "@/types/tablos.types";
|
||||
import { ColorMap } from "@/constants/colors";
|
||||
import {
|
||||
ColorMap,
|
||||
PRIMARY,
|
||||
PRIMARY_SURFACE,
|
||||
PRIMARY_SURFACE_DARK,
|
||||
} from "@/constants/colors";
|
||||
import { useThemeColor } from "@/hooks/useThemeColor";
|
||||
import { useColorScheme } from "@/hooks/useColorScheme";
|
||||
|
||||
|
|
@ -89,7 +94,7 @@ export default function PlanningScreen() {
|
|||
const emptyIconColor = colorScheme === "dark" ? "#6b7280" : "#d1d5db";
|
||||
const viewModeToggleColor = colorScheme === "dark" ? "#374151" : "#f3f4f6";
|
||||
const weekHeaderBorderColor = colorScheme === "dark" ? "#374151" : "#e5e7eb";
|
||||
const selectedOptionBgColor = colorScheme === "dark" ? "#1e3a8a" : "#eff6ff";
|
||||
const selectedOptionBgColor = colorScheme === "dark" ? PRIMARY_SURFACE_DARK : PRIMARY_SURFACE;
|
||||
|
||||
const filteredEvents: EventAndTablo[] =
|
||||
(selectedTablo === null
|
||||
|
|
@ -100,7 +105,6 @@ export default function PlanningScreen() {
|
|||
const year = currentMonth.getFullYear();
|
||||
const month = currentMonth.getMonth();
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
const startDate = new Date(firstDay);
|
||||
|
||||
// Adjust for Monday as first day of week
|
||||
|
|
@ -204,7 +208,7 @@ export default function PlanningScreen() {
|
|||
style={[
|
||||
styles.tabloColorDot,
|
||||
{
|
||||
backgroundColor: ColorMap[item.color ?? "bg-gray-500"],
|
||||
backgroundColor: ColorMap[item.color ?? "bg-purple-500"],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
|
@ -212,7 +216,7 @@ export default function PlanningScreen() {
|
|||
<Text style={[styles.tabloOptionName, { color: textColor }]}>{item.name}</Text>
|
||||
</View>
|
||||
</View>
|
||||
{selectedTablo?.id === item.id && <Check size={20} color="#3b82f6" />}
|
||||
{selectedTablo?.id === item.id && <Check size={20} color={PRIMARY} />}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
|
|
@ -314,7 +318,7 @@ export default function PlanningScreen() {
|
|||
style={[
|
||||
styles.weekEventCircle,
|
||||
{
|
||||
backgroundColor: ColorMap[event.tablo_color ?? "bg-gray-500"],
|
||||
backgroundColor: ColorMap[event.tablo_color ?? "bg-purple-500"],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
|
@ -365,7 +369,7 @@ export default function PlanningScreen() {
|
|||
<CalendarIcon size={40} color={emptyIconColor} />
|
||||
<Text style={[styles.emptyStateTitle, { color: textColor }]}>Aucun événement</Text>
|
||||
<Text style={[styles.emptyStateText, { color: subtitleColor }]}>
|
||||
Vous n'avez aucun événement prévu pour cette date.
|
||||
{"Vous n'avez aucun événement prévu pour cette date."}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
|
@ -376,7 +380,6 @@ export default function PlanningScreen() {
|
|||
const calendarDays = generateCalendarDays();
|
||||
const weekDays = generateWeekDays();
|
||||
const todayEvents = getEventsForDate(selectedDate);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={[styles.container, { backgroundColor }]}
|
||||
|
|
@ -406,7 +409,10 @@ export default function PlanningScreen() {
|
|||
<View style={styles.viewModeContainer}>
|
||||
<View style={[styles.viewModeToggle, { backgroundColor: viewModeToggleColor }]}>
|
||||
<TouchableOpacity
|
||||
style={[styles.viewModeButton, viewMode === "month" && styles.activeViewModeButton]}
|
||||
style={[
|
||||
styles.viewModeButton,
|
||||
viewMode === "month" && [styles.activeViewModeButton, { backgroundColor: PRIMARY }],
|
||||
]}
|
||||
onPress={() => setViewMode("month")}
|
||||
>
|
||||
<Grid3x3 size={18} color={viewMode === "month" ? "white" : subtitleColor} />
|
||||
|
|
@ -421,7 +427,10 @@ export default function PlanningScreen() {
|
|||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.viewModeButton, viewMode === "week" && styles.activeViewModeButton]}
|
||||
style={[
|
||||
styles.viewModeButton,
|
||||
viewMode === "week" && [styles.activeViewModeButton, { backgroundColor: PRIMARY }],
|
||||
]}
|
||||
onPress={() => setViewMode("week")}
|
||||
>
|
||||
<Rows3 size={18} color={viewMode === "week" ? "white" : subtitleColor} />
|
||||
|
|
@ -457,7 +466,7 @@ export default function PlanningScreen() {
|
|||
{
|
||||
backgroundColor: selectedTablo?.color
|
||||
? ColorMap[selectedTablo.color]
|
||||
: subtitleColor,
|
||||
: PRIMARY,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
|
@ -477,7 +486,7 @@ export default function PlanningScreen() {
|
|||
<TouchableOpacity
|
||||
onPress={() => (viewMode === "month" ? navigateMonth("prev") : navigateWeek("prev"))}
|
||||
>
|
||||
<ChevronLeft size={24} color="#3b82f6" />
|
||||
<ChevronLeft size={24} color={PRIMARY} />
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.calendarTitle, { color: textColor }]}>
|
||||
{viewMode === "month"
|
||||
|
|
@ -487,7 +496,7 @@ export default function PlanningScreen() {
|
|||
<TouchableOpacity
|
||||
onPress={() => (viewMode === "month" ? navigateMonth("next") : navigateWeek("next"))}
|
||||
>
|
||||
<ChevronRight size={24} color="#3b82f6" />
|
||||
<ChevronRight size={24} color={PRIMARY} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
|
|
@ -519,7 +528,7 @@ export default function PlanningScreen() {
|
|||
{viewMode === "month" && (
|
||||
<View style={styles.eventsSection}>
|
||||
<View style={styles.eventsSectionHeader}>
|
||||
<CalendarIcon size={20} color="#3b82f6" />
|
||||
<CalendarIcon size={20} color={PRIMARY} />
|
||||
<Text style={[styles.eventsSectionTitle, { color: textColor }]}>
|
||||
Événements du jour ({todayEvents.length})
|
||||
</Text>
|
||||
|
|
@ -538,7 +547,7 @@ export default function PlanningScreen() {
|
|||
<CalendarIcon size={40} color={emptyIconColor} />
|
||||
<Text style={[styles.emptyStateTitle, { color: textColor }]}>Aucun événement</Text>
|
||||
<Text style={[styles.emptyStateText, { color: subtitleColor }]}>
|
||||
Vous n'avez aucun événement prévu pour cette date.
|
||||
{"Vous n'avez aucun événement prévu pour cette date."}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
|
@ -588,7 +597,7 @@ export default function PlanningScreen() {
|
|||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{selectedTablo === null && <Check size={20} color="#3b82f6" />}
|
||||
{selectedTablo === null && <Check size={20} color={PRIMARY} />}
|
||||
</TouchableOpacity>
|
||||
}
|
||||
/>
|
||||
|
|
@ -707,7 +716,7 @@ export default function PlanningScreen() {
|
|||
style={[
|
||||
styles.tabloOptionNameInForm,
|
||||
{
|
||||
color: newEvent.tablo_id === item.id ? "#3b82f6" : textColor,
|
||||
color: newEvent.tablo_id === item.id ? PRIMARY : textColor,
|
||||
},
|
||||
newEvent.tablo_id === item.id && styles.selectedTabloOptionNameInForm,
|
||||
]}
|
||||
|
|
@ -715,7 +724,7 @@ export default function PlanningScreen() {
|
|||
{item.name}
|
||||
</Text>
|
||||
</View>
|
||||
{newEvent.tablo_id === item.id && <Check size={20} color="#3b82f6" />}
|
||||
{newEvent.tablo_id === item.id && <Check size={20} color={PRIMARY} />}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
keyExtractor={(item) => item.id}
|
||||
|
|
@ -772,10 +781,10 @@ const styles = StyleSheet.create({
|
|||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
backgroundColor: "#3b82f6",
|
||||
backgroundColor: PRIMARY,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
shadowColor: "#3b82f6",
|
||||
shadowColor: PRIMARY,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
|
|
@ -802,7 +811,7 @@ const styles = StyleSheet.create({
|
|||
gap: 6,
|
||||
},
|
||||
activeViewModeButton: {
|
||||
backgroundColor: "#3b82f6",
|
||||
backgroundColor: PRIMARY,
|
||||
},
|
||||
viewModeText: {
|
||||
fontSize: 14,
|
||||
|
|
@ -916,7 +925,7 @@ const styles = StyleSheet.create({
|
|||
// color is set dynamically
|
||||
},
|
||||
selectedDay: {
|
||||
backgroundColor: "#3b82f6",
|
||||
backgroundColor: PRIMARY,
|
||||
borderRadius: 20,
|
||||
},
|
||||
selectedDayText: {
|
||||
|
|
@ -963,7 +972,7 @@ const styles = StyleSheet.create({
|
|||
marginBottom: 8,
|
||||
},
|
||||
selectedWeekDay: {
|
||||
backgroundColor: "#3b82f6",
|
||||
backgroundColor: PRIMARY,
|
||||
},
|
||||
todaySelectedWeekDay: {
|
||||
backgroundColor: "#f5100b",
|
||||
|
|
@ -1291,7 +1300,7 @@ const styles = StyleSheet.create({
|
|||
marginLeft: 8,
|
||||
},
|
||||
selectedTabloOptionNameInForm: {
|
||||
color: "#3b82f6",
|
||||
color: PRIMARY,
|
||||
},
|
||||
createEventModalActions: {
|
||||
flexDirection: "row",
|
||||
|
|
@ -1319,7 +1328,7 @@ const styles = StyleSheet.create({
|
|||
paddingVertical: 12,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 8,
|
||||
backgroundColor: "#3b82f6",
|
||||
backgroundColor: PRIMARY,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,11 @@ import {
|
|||
Heart,
|
||||
} from "lucide-react-native";
|
||||
import { router } from "expo-router";
|
||||
import {
|
||||
PRIMARY,
|
||||
PRIMARY_DARK,
|
||||
PRIMARY_LIGHT,
|
||||
} from "@/constants/colors";
|
||||
import { useThemeColor } from "@/hooks/useThemeColor";
|
||||
import { useColorScheme } from "@/hooks/useColorScheme";
|
||||
|
||||
|
|
@ -46,7 +51,9 @@ export default function SettingsScreen() {
|
|||
|
||||
// Theme-aware gradient colors
|
||||
const gradientColors: [string, string, string] =
|
||||
colorScheme === "dark" ? ["#1f2937", "#374151", "#4b5563"] : ["#1e3a8a", "#3b82f6", "#60a5fa"];
|
||||
colorScheme === "dark"
|
||||
? ["#2F2548", "#4B3384", PRIMARY_DARK]
|
||||
: [PRIMARY_DARK, PRIMARY, PRIMARY_LIGHT];
|
||||
|
||||
// Settings state
|
||||
const [pushNotifications, setPushNotifications] = useState(true);
|
||||
|
|
@ -141,7 +148,7 @@ export default function SettingsScreen() {
|
|||
onValueChange={onValueChange}
|
||||
trackColor={{
|
||||
false: colorScheme === "dark" ? "#374151" : "#e5e7eb",
|
||||
true: "#3b82f6",
|
||||
true: PRIMARY,
|
||||
}}
|
||||
thumbColor={value ? "#ffffff" : "#ffffff"}
|
||||
ios_backgroundColor={colorScheme === "dark" ? "#374151" : "#e5e7eb"}
|
||||
|
|
@ -181,7 +188,7 @@ export default function SettingsScreen() {
|
|||
"Compte",
|
||||
<>
|
||||
{renderSettingsItem(
|
||||
<User size={20} color="#3b82f6" />,
|
||||
<User size={20} color={PRIMARY} />,
|
||||
"Profil utilisateur",
|
||||
`${user.name || "Non défini"} • ${user.email}`,
|
||||
() => router.push("/user"),
|
||||
|
|
|
|||
|
|
@ -29,7 +29,13 @@ import {
|
|||
X,
|
||||
} from "lucide-react-native";
|
||||
import { router } from "expo-router";
|
||||
import { AVAILABLE_COLORS, ColorMap } from "@/constants/colors";
|
||||
import {
|
||||
AVAILABLE_COLORS,
|
||||
ColorMap,
|
||||
PRIMARY,
|
||||
PRIMARY_DARK,
|
||||
PRIMARY_LIGHT,
|
||||
} from "@/constants/colors";
|
||||
import { useThemeColor } from "@/hooks/useThemeColor";
|
||||
import { useColorScheme } from "@/hooks/useColorScheme";
|
||||
|
||||
|
|
@ -57,7 +63,9 @@ export default function TablosScreen() {
|
|||
|
||||
// Theme-aware gradient colors
|
||||
const gradientColors: [string, string, string] =
|
||||
colorScheme === "dark" ? ["#1f2937", "#374151", "#4b5563"] : ["#1e3a8a", "#3b82f6", "#60a5fa"];
|
||||
colorScheme === "dark"
|
||||
? ["#2F2548", "#4B3384", PRIMARY_DARK]
|
||||
: [PRIMARY_DARK, PRIMARY, PRIMARY_LIGHT];
|
||||
|
||||
const [newTablo, setNewTablo] = useState<{
|
||||
name: string;
|
||||
|
|
@ -65,7 +73,7 @@ export default function TablosScreen() {
|
|||
status: "todo" | "in_progress" | "done";
|
||||
}>({
|
||||
name: "",
|
||||
color: "bg-blue-500",
|
||||
color: "bg-purple-500",
|
||||
status: "todo",
|
||||
});
|
||||
|
||||
|
|
@ -158,7 +166,7 @@ export default function TablosScreen() {
|
|||
setIsCreateModalVisible(false);
|
||||
setNewTablo({
|
||||
name: "",
|
||||
color: "bg-blue-500",
|
||||
color: "bg-purple-500",
|
||||
status: "todo",
|
||||
});
|
||||
};
|
||||
|
|
@ -372,19 +380,19 @@ export default function TablosScreen() {
|
|||
|
||||
<View style={styles.headerActions}>
|
||||
<TouchableOpacity style={styles.searchButton}>
|
||||
<Search size={20} color="#3b82f6" />
|
||||
<Search size={20} color={PRIMARY} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.filterButton} onPress={showFilterOptions}>
|
||||
<Filter size={20} color="#3b82f6" />
|
||||
<Filter size={20} color={PRIMARY} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.viewToggle}
|
||||
onPress={() => setViewMode(viewMode === "grid" ? "list" : "grid")}
|
||||
>
|
||||
{viewMode === "grid" ? (
|
||||
<List size={20} color="#3b82f6" />
|
||||
<List size={20} color={PRIMARY} />
|
||||
) : (
|
||||
<Grid3X3 size={20} color="#3b82f6" />
|
||||
<Grid3X3 size={20} color={PRIMARY} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
|
@ -702,7 +710,7 @@ const styles = StyleSheet.create({
|
|||
createButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#3b82f6",
|
||||
backgroundColor: PRIMARY,
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 25,
|
||||
|
|
@ -914,7 +922,7 @@ const styles = StyleSheet.create({
|
|||
lineHeight: 24,
|
||||
},
|
||||
retryButton: {
|
||||
backgroundColor: "#3b82f6",
|
||||
backgroundColor: PRIMARY,
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
|
|
@ -931,7 +939,7 @@ const styles = StyleSheet.create({
|
|||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: "#3b82f6",
|
||||
backgroundColor: PRIMARY,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
shadowColor: "#000",
|
||||
|
|
@ -1018,14 +1026,14 @@ const styles = StyleSheet.create({
|
|||
alignItems: "center",
|
||||
},
|
||||
selectedStatusOption: {
|
||||
borderColor: "#3b82f6",
|
||||
borderColor: PRIMARY,
|
||||
},
|
||||
statusOptionText: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
},
|
||||
modalButton: {
|
||||
backgroundColor: "#3b82f6",
|
||||
backgroundColor: PRIMARY,
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 25,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useRef, useCallback, useMemo } from "react";
|
||||
import React, { useState, useRef, useCallback, useMemo, useEffect } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
|
|
@ -10,10 +10,18 @@ import {
|
|||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ActivityIndicator,
|
||||
Image,
|
||||
} from "react-native";
|
||||
import { useLocalSearchParams, router } from "expo-router";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
import { ArrowLeft, Send, MessageCircle } from "lucide-react-native";
|
||||
import { PRIMARY } from "@/constants/colors";
|
||||
import {
|
||||
buildTimelineItems,
|
||||
type MessageGroup,
|
||||
type TimelineItem,
|
||||
} from "@/features/chat/channelTimeline";
|
||||
import { getMessageAuthorDisplay } from "@/features/chat/messageAuthor";
|
||||
import { useThemeColor } from "@/hooks/useThemeColor";
|
||||
import { useColorScheme } from "@/hooks/useColorScheme";
|
||||
import { useChat } from "@/hooks/chat";
|
||||
|
|
@ -25,13 +33,6 @@ import { useAuthStore } from "@/stores/auth";
|
|||
|
||||
const MESSAGE_GROUP_INTERVAL = 2 * 60 * 1000; // 2 minutes in ms
|
||||
|
||||
type MessageGroup = {
|
||||
key: string;
|
||||
userId: string;
|
||||
messages: ChatMessage[];
|
||||
isOwn: boolean;
|
||||
};
|
||||
|
||||
function groupMessages(messages: ChatMessage[], currentUserId: string): MessageGroup[] {
|
||||
const groups: MessageGroup[] = [];
|
||||
|
||||
|
|
@ -61,6 +62,7 @@ export default function ChannelScreen() {
|
|||
const { cid } = useLocalSearchParams<{ cid: string }>();
|
||||
const channelId = cid;
|
||||
const session = useAuthStore((state) => state.session);
|
||||
const currentUser = useAuthStore((state) => state.user);
|
||||
const currentUserId = session?.user?.id ?? "";
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
|
@ -72,7 +74,7 @@ export default function ChannelScreen() {
|
|||
const inputBg = useThemeColor({ light: "#f1f5f9", dark: "#374151" }, "background");
|
||||
const headerBg = useThemeColor({ light: "#ffffff", dark: "#1f2937" }, "background");
|
||||
const borderColor = isDark ? "#374151" : "#e5e7eb";
|
||||
const ownBubbleBg = "#3b82f6";
|
||||
const ownBubbleBg = PRIMARY;
|
||||
const otherBubbleBg = isDark ? "#374151" : "#e5e7eb";
|
||||
|
||||
const {
|
||||
|
|
@ -92,8 +94,13 @@ export default function ChannelScreen() {
|
|||
const tablo = tablos?.find((t) => t.id === channelId);
|
||||
|
||||
const [inputText, setInputText] = useState("");
|
||||
const [failedAvatarUserIds, setFailedAvatarUserIds] = useState<Record<string, boolean>>({});
|
||||
const flatListRef = useRef<FlatList>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setFailedAvatarUserIds({});
|
||||
}, [channelId]);
|
||||
|
||||
// Build user lookup map
|
||||
const userMap = useMemo(() => {
|
||||
const map: Record<string, TabloMember> = {};
|
||||
|
|
@ -109,11 +116,13 @@ export default function ChannelScreen() {
|
|||
}, [markAsRead, queryClient])
|
||||
);
|
||||
|
||||
// Group messages for display (reversed for inverted FlatList)
|
||||
const messageGroups = useMemo(
|
||||
() => groupMessages(messages, currentUserId).reverse(),
|
||||
[messages, currentUserId]
|
||||
);
|
||||
const separatorColor = isDark ? "#4b5563" : "#d1d5db";
|
||||
|
||||
// Build timeline items in chronological order, then reverse for inverted FlatList.
|
||||
const timelineItems = useMemo(() => {
|
||||
const messageGroups = groupMessages(messages, currentUserId);
|
||||
return buildTimelineItems(messageGroups, new Date()).reverse();
|
||||
}, [messages, currentUserId]);
|
||||
|
||||
const handleSend = () => {
|
||||
const text = inputText.trim();
|
||||
|
|
@ -135,34 +144,51 @@ export default function ChannelScreen() {
|
|||
return `${names.join(" et ")} sont en train d'écrire...`;
|
||||
}, [typingUsers, userMap]);
|
||||
|
||||
const renderMessageGroup = ({ item }: { item: MessageGroup }) => {
|
||||
const user = userMap[item.userId];
|
||||
const senderName = user?.name ?? "Utilisateur";
|
||||
const initial = senderName.charAt(0).toUpperCase();
|
||||
const renderMessageGroup = (item: MessageGroup) => {
|
||||
const author = getMessageAuthorDisplay({
|
||||
userId: item.userId,
|
||||
members: userMap,
|
||||
currentUser,
|
||||
failedAvatarUserIds,
|
||||
});
|
||||
const timestamp = new Date(item.messages[0].createdAt).toLocaleTimeString("fr-FR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
const avatar = author.avatarUrl ? (
|
||||
<Image
|
||||
source={{ uri: author.avatarUrl }}
|
||||
style={styles.avatarImage}
|
||||
onError={() => {
|
||||
setFailedAvatarUserIds((prev) =>
|
||||
prev[item.userId] ? prev : { ...prev, [item.userId]: true }
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.avatar}>
|
||||
<Text style={styles.avatarText}>{author.initial}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={[styles.groupContainer, item.isOwn && styles.groupContainerOwn]}>
|
||||
{/* Avatar + name for other users */}
|
||||
{!item.isOwn && (
|
||||
<View style={styles.senderRow}>
|
||||
<View style={styles.avatar}>
|
||||
<Text style={styles.avatarText}>{initial}</Text>
|
||||
</View>
|
||||
<Text style={[styles.senderName, { color: subtextColor }]}>{senderName}</Text>
|
||||
<Text style={[styles.timestamp, { color: subtextColor }]}>{timestamp}</Text>
|
||||
</View>
|
||||
)}
|
||||
{item.isOwn && (
|
||||
<Text style={[styles.timestamp, styles.timestampOwn, { color: subtextColor }]}>
|
||||
{timestamp}
|
||||
</Text>
|
||||
)}
|
||||
<View style={[styles.senderRow, item.isOwn && styles.senderRowOwn]}>
|
||||
{item.isOwn ? (
|
||||
<>
|
||||
<Text style={[styles.timestamp, { color: subtextColor }]}>{timestamp}</Text>
|
||||
<Text style={[styles.senderName, { color: subtextColor }]}>{author.name}</Text>
|
||||
{avatar}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{avatar}
|
||||
<Text style={[styles.senderName, { color: subtextColor }]}>{author.name}</Text>
|
||||
<Text style={[styles.timestamp, { color: subtextColor }]}>{timestamp}</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Message bubbles */}
|
||||
{item.messages.map((msg) => (
|
||||
<View
|
||||
key={msg.id}
|
||||
|
|
@ -183,6 +209,20 @@ export default function ChannelScreen() {
|
|||
);
|
||||
};
|
||||
|
||||
const renderTimelineItem = ({ item }: { item: TimelineItem }) => {
|
||||
if (item.type === "day-separator") {
|
||||
return (
|
||||
<View style={styles.daySeparator}>
|
||||
<View style={[styles.daySeparatorLine, { backgroundColor: separatorColor }]} />
|
||||
<Text style={[styles.daySeparatorLabel, { color: subtextColor }]}>{item.label}</Text>
|
||||
<View style={[styles.daySeparatorLine, { backgroundColor: separatorColor }]} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return renderMessageGroup(item.group);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: bgColor }]}>
|
||||
{/* Header */}
|
||||
|
|
@ -227,15 +267,15 @@ export default function ChannelScreen() {
|
|||
) : (
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={messageGroups}
|
||||
data={timelineItems}
|
||||
keyExtractor={(item) => item.key}
|
||||
renderItem={renderMessageGroup}
|
||||
renderItem={renderTimelineItem}
|
||||
inverted
|
||||
onEndReached={loadMoreMessages}
|
||||
onEndReachedThreshold={0.3}
|
||||
ListFooterComponent={
|
||||
hasMoreMessages ? (
|
||||
<ActivityIndicator style={styles.loadingMore} color="#3b82f6" />
|
||||
<ActivityIndicator style={styles.loadingMore} color={PRIMARY} />
|
||||
) : null
|
||||
}
|
||||
contentContainerStyle={styles.messageListContent}
|
||||
|
|
@ -265,7 +305,7 @@ export default function ChannelScreen() {
|
|||
onPress={handleSend}
|
||||
disabled={!inputText.trim()}
|
||||
>
|
||||
<Send size={20} color={inputText.trim() ? "#3b82f6" : "#9ca3af"} />
|
||||
<Send size={20} color={inputText.trim() ? PRIMARY : "#9ca3af"} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
|
|
@ -330,6 +370,23 @@ const styles = StyleSheet.create({
|
|||
loadingMore: {
|
||||
paddingVertical: 16,
|
||||
},
|
||||
daySeparator: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
alignSelf: "stretch",
|
||||
gap: 12,
|
||||
marginVertical: 12,
|
||||
},
|
||||
daySeparatorLine: {
|
||||
flex: 1,
|
||||
height: StyleSheet.hairlineWidth,
|
||||
},
|
||||
daySeparatorLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: "600",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.4,
|
||||
},
|
||||
groupContainer: {
|
||||
marginBottom: 12,
|
||||
maxWidth: "80%",
|
||||
|
|
@ -344,14 +401,22 @@ const styles = StyleSheet.create({
|
|||
gap: 6,
|
||||
marginBottom: 4,
|
||||
},
|
||||
senderRowOwn: {
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
avatar: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
backgroundColor: "#3b82f6",
|
||||
backgroundColor: PRIMARY,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
avatarImage: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
},
|
||||
avatarText: {
|
||||
color: "#ffffff",
|
||||
fontSize: 11,
|
||||
|
|
@ -364,10 +429,6 @@ const styles = StyleSheet.create({
|
|||
timestamp: {
|
||||
fontSize: 11,
|
||||
},
|
||||
timestampOwn: {
|
||||
textAlign: "right",
|
||||
marginBottom: 4,
|
||||
},
|
||||
bubble: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { useThemeColor } from "@/hooks/useThemeColor";
|
|||
import { useColorScheme } from "@/hooks/useColorScheme";
|
||||
import { useTablosList } from "@/hooks/tablos";
|
||||
import { Etape } from "@/types/tasks.types";
|
||||
import { ColorMap } from "@/constants/colors";
|
||||
import { ColorMap, PRIMARY } from "@/constants/colors";
|
||||
import TaskList from "@/components/tasks/TaskList";
|
||||
import EtapeSheet from "@/components/tasks/EtapeSheet";
|
||||
|
||||
|
|
@ -48,7 +48,7 @@ export default function TabloDetailScreen() {
|
|||
);
|
||||
}
|
||||
|
||||
const tabloColor = tablo.color ? ColorMap[tablo.color] ?? "#3b82f6" : "#3b82f6";
|
||||
const tabloColor = tablo.color ? ColorMap[tablo.color] ?? PRIMARY : PRIMARY;
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: bgColor }]}>
|
||||
|
|
@ -121,7 +121,7 @@ const styles = StyleSheet.create({
|
|||
width: 54,
|
||||
height: 54,
|
||||
borderRadius: 27,
|
||||
backgroundColor: "#3b82f6",
|
||||
backgroundColor: PRIMARY,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
shadowColor: "#000",
|
||||
|
|
|
|||
|
|
@ -13,12 +13,13 @@ import {
|
|||
import { useLocalSearchParams, router } from "expo-router";
|
||||
import { ArrowLeft, User, Layers, Calendar } from "lucide-react-native";
|
||||
import DateTimePicker from "@react-native-community/datetimepicker";
|
||||
import { PRIMARY } from "@/constants/colors";
|
||||
import { useThemeColor } from "@/hooks/useThemeColor";
|
||||
import { useColorScheme } from "@/hooks/useColorScheme";
|
||||
import { TabloMember, useTabloMembers } from "@/hooks/members";
|
||||
import { useTasksByTablo, useCreateTask, useUpdateTask, useDeleteTask } from "@/hooks/tasks";
|
||||
import { useTabloEtapes } from "@/hooks/etapes";
|
||||
import { useTabloMembers } from "@/hooks/members";
|
||||
import { TaskStatus, TASK_STATUSES } from "@/types/tasks.types";
|
||||
import { TaskStatus } from "@/types/tasks.types";
|
||||
import StatusControl from "@/components/tasks/StatusControl";
|
||||
import AssigneePicker from "@/components/tasks/AssigneePicker";
|
||||
import EtapePicker from "@/components/tasks/EtapePicker";
|
||||
|
|
@ -126,7 +127,7 @@ export default function TaskDetailScreen() {
|
|||
};
|
||||
|
||||
// Derived display values
|
||||
const assigneeName = members?.find((m) => m.id === assigneeId)?.name ?? "Non assigné";
|
||||
const assigneeName = members?.find((member: TabloMember) => member.id === assigneeId)?.name ?? "Non assigné";
|
||||
const etapeName = etapes?.find((e) => e.id === parentTaskId)?.title ?? "Sans Étape";
|
||||
const dueDateDisplay = dueDate
|
||||
? dueDate.toLocaleDateString("fr-FR", { day: "numeric", month: "long", year: "numeric" })
|
||||
|
|
@ -317,7 +318,7 @@ const styles = StyleSheet.create({
|
|||
fontSize: 15,
|
||||
},
|
||||
saveButton: {
|
||||
backgroundColor: "#3b82f6",
|
||||
backgroundColor: PRIMARY,
|
||||
borderRadius: 12,
|
||||
paddingVertical: 14,
|
||||
alignItems: "center",
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { View, StyleSheet, ScrollView, Text, TouchableOpacity } from "react-native";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { Avatar, Input } from "@rn-vui/themed";
|
||||
import { Card } from "@rn-vui/themed";
|
||||
import { useState } from "react";
|
||||
import { Avatar, Card } from "@rn-vui/themed";
|
||||
import { PRIMARY, PRIMARY_DARK, PRIMARY_LIGHT } from "@/constants/colors";
|
||||
import { useUser } from "@/providers/UserProvider";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { User, Mail, Edit3, Check, LogOut, Settings, Shield } from "lucide-react-native";
|
||||
import { User, Mail, LogOut } from "lucide-react-native";
|
||||
import { Stack } from "expo-router";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
|
|
@ -27,7 +26,7 @@ export default function ProfileScreen() {
|
|||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<ScrollView style={styles.container} showsVerticalScrollIndicator={false}>
|
||||
<LinearGradient
|
||||
colors={["#1e3a8a", "#3b82f6", "#60a5fa"]}
|
||||
colors={[PRIMARY_DARK, PRIMARY, PRIMARY_LIGHT]}
|
||||
style={[styles.headerGradient, { paddingTop: insets.top + 60 }]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
|
|
@ -50,7 +49,7 @@ export default function ProfileScreen() {
|
|||
{/* Carte d'informations personnelles */}
|
||||
<Card containerStyle={styles.mainCard}>
|
||||
<View style={styles.cardHeader}>
|
||||
<User size={20} color="#3b82f6" />
|
||||
<User size={20} color={PRIMARY} />
|
||||
<Text style={styles.cardHeaderTitle}>Informations personnelles</Text>
|
||||
</View>
|
||||
|
||||
|
|
@ -71,7 +70,7 @@ export default function ProfileScreen() {
|
|||
<User size={18} color="#6b7280" />
|
||||
</View>
|
||||
<View style={styles.infoContent}>
|
||||
<Text style={styles.infoLabel}>Nom d'affichage</Text>
|
||||
<Text style={styles.infoLabel}>{"Nom d'affichage"}</Text>
|
||||
<Text style={styles.infoValue}>{user.name}</Text>
|
||||
{/* {isEditing ? (
|
||||
<Input
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export default function NotFoundScreen() {
|
|||
<>
|
||||
<Stack.Screen options={{ title: "Oops!" }} />
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedText type="title">This screen doesn't exist.</ThemedText>
|
||||
<ThemedText type="title">{"This screen doesn't exist."}</ThemedText>
|
||||
<Link href="/" style={styles.link}>
|
||||
<ThemedText type="link">Go to home screen!</ThemedText>
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import React, { useState } from "react";
|
||||
import { StyleSheet, View, Text, Image, ImageSourcePropType, Platform } from "react-native";
|
||||
import { StyleSheet, View, Text, Image, Platform } from "react-native";
|
||||
import { Button, Input } from "@rn-vui/themed";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { Link } from "expo-router";
|
||||
import { Mail, Lock } from "lucide-react-native";
|
||||
import { GoogleLoginButton } from "@/components/GoogleLoginButton";
|
||||
import { AppleLoginButton } from "@/components/AppleLoginButton";
|
||||
import { PRIMARY, PRIMARY_LIGHT } from "@/constants/colors";
|
||||
import { useThemeColor } from "@/hooks/useThemeColor";
|
||||
import { useColorScheme } from "@/hooks/useColorScheme";
|
||||
|
||||
|
|
@ -23,7 +24,7 @@ export default function Auth() {
|
|||
const textColor = useThemeColor({ light: "#333", dark: "#f9fafb" }, "text");
|
||||
const subtitleColor = useThemeColor({ light: "#666", dark: "#9ca3af" }, "text");
|
||||
const separatorColor = useThemeColor({ light: "#ddd", dark: "#374151" }, "text");
|
||||
const linkColor = useThemeColor({ light: "#3b82f6", dark: "#60a5fa" }, "text");
|
||||
const linkColor = useThemeColor({ light: PRIMARY, dark: PRIMARY_LIGHT }, "text");
|
||||
|
||||
const dark = useColorScheme() === "dark";
|
||||
|
||||
|
|
@ -83,7 +84,7 @@ export default function Auth() {
|
|||
<View style={styles.linkContainer}>
|
||||
<Text style={[styles.linkText, { color: subtitleColor }]}>Pas encore de compte ? </Text>
|
||||
<Link href="/signup" style={[styles.link, { color: linkColor }]}>
|
||||
S'inscrire
|
||||
{"S'inscrire"}
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
|
|
@ -134,7 +135,7 @@ const styles = StyleSheet.create({
|
|||
button: {
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8, // Rounded corners
|
||||
backgroundColor: "#007bff", // Added background color for consistency
|
||||
backgroundColor: PRIMARY,
|
||||
},
|
||||
buttonTitle: {
|
||||
fontWeight: "bold",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Button, Input } from "@rn-vui/themed";
|
|||
import { useAuthStore } from "@/stores/auth";
|
||||
import { Link } from "expo-router";
|
||||
import { Mail, Lock, User, Building2 } from "lucide-react-native";
|
||||
import { PRIMARY, PRIMARY_LIGHT } from "@/constants/colors";
|
||||
import { useThemeColor } from "@/hooks/useThemeColor";
|
||||
import { useColorScheme } from "@/hooks/useColorScheme";
|
||||
|
||||
|
|
@ -21,7 +22,7 @@ export default function SignUp() {
|
|||
const backgroundColor = useThemeColor({ light: "#f5f5f5", dark: "#111827" }, "background");
|
||||
const textColor = useThemeColor({ light: "#333", dark: "#f9fafb" }, "text");
|
||||
const subtitleColor = useThemeColor({ light: "#666", dark: "#9ca3af" }, "text");
|
||||
const linkColor = useThemeColor({ light: "#3b82f6", dark: "#60a5fa" }, "text");
|
||||
const linkColor = useThemeColor({ light: PRIMARY, dark: PRIMARY_LIGHT }, "text");
|
||||
|
||||
const dark = useColorScheme() === "dark";
|
||||
|
||||
|
|
@ -150,7 +151,7 @@ const styles = StyleSheet.create({
|
|||
button: {
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
backgroundColor: "#007bff", // Example primary color
|
||||
backgroundColor: PRIMARY,
|
||||
},
|
||||
buttonTitle: {
|
||||
fontWeight: "bold",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { StyleSheet, TouchableOpacity } from "react-native";
|
|||
import { ThemedText } from "@/components/ThemedText";
|
||||
import { ThemedView } from "@/components/ThemedView";
|
||||
import { IconSymbol } from "@/components/ui/IconSymbol";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { Colors } from "@/constants/colors";
|
||||
import { useColorScheme } from "@/hooks/useColorScheme";
|
||||
|
||||
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export const LoadingView = () => {
|
|||
XTablo
|
||||
</ThemedText>
|
||||
<ThemedText type="subtitle" style={[styles.subtitle, { color: subtitleColor }]}>
|
||||
Initialisation de l'application...
|
||||
{"Initialisation de l'application..."}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</ThemedView>
|
||||
|
|
|
|||
|
|
@ -3,8 +3,12 @@ import renderer from "react-test-renderer";
|
|||
|
||||
import { ThemedText } from "../ThemedText";
|
||||
|
||||
it(`renders correctly`, () => {
|
||||
const tree = renderer.create(<ThemedText>Snapshot test!</ThemedText>).toJSON();
|
||||
it(`renders correctly`, async () => {
|
||||
let component: renderer.ReactTestRenderer;
|
||||
|
||||
expect(tree).toMatchSnapshot();
|
||||
await renderer.act(async () => {
|
||||
component = renderer.create(<ThemedText>Snapshot test!</ThemedText>);
|
||||
});
|
||||
|
||||
expect(component!.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
StyleSheet,
|
||||
} from "react-native";
|
||||
import { X, Check } from "lucide-react-native";
|
||||
import { PRIMARY } from "@/constants/colors";
|
||||
import { useThemeColor } from "@/hooks/useThemeColor";
|
||||
import { useColorScheme } from "@/hooks/useColorScheme";
|
||||
import { TabloMember } from "@/hooks/members";
|
||||
|
|
@ -56,7 +57,7 @@ export default function AssigneePicker({
|
|||
>
|
||||
<View style={[styles.avatar, styles.avatarEmpty]} />
|
||||
<Text style={[styles.name, { color: subtextColor }]}>Non assigné</Text>
|
||||
{selectedId === null && <Check size={18} color="#3b82f6" />}
|
||||
{selectedId === null && <Check size={18} color={PRIMARY} />}
|
||||
</TouchableOpacity>
|
||||
|
||||
<FlatList
|
||||
|
|
@ -73,7 +74,7 @@ export default function AssigneePicker({
|
|||
</Text>
|
||||
</View>
|
||||
<Text style={[styles.name, { color: textColor }]}>{item.name}</Text>
|
||||
{selectedId === item.id && <Check size={18} color="#3b82f6" />}
|
||||
{selectedId === item.id && <Check size={18} color={PRIMARY} />}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
|
|
@ -118,7 +119,7 @@ const styles = StyleSheet.create({
|
|||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: "#3b82f6",
|
||||
backgroundColor: PRIMARY,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
StyleSheet,
|
||||
} from "react-native";
|
||||
import { X, Check } from "lucide-react-native";
|
||||
import { PRIMARY } from "@/constants/colors";
|
||||
import { useThemeColor } from "@/hooks/useThemeColor";
|
||||
import { useColorScheme } from "@/hooks/useColorScheme";
|
||||
import { Etape } from "@/types/tasks.types";
|
||||
|
|
@ -55,7 +56,7 @@ export default function EtapePicker({
|
|||
onPress={() => handleSelect(null)}
|
||||
>
|
||||
<Text style={[styles.name, { color: subtextColor }]}>Sans Étape</Text>
|
||||
{selectedId === null && <Check size={18} color="#3b82f6" />}
|
||||
{selectedId === null && <Check size={18} color={PRIMARY} />}
|
||||
</TouchableOpacity>
|
||||
|
||||
<FlatList
|
||||
|
|
@ -67,7 +68,7 @@ export default function EtapePicker({
|
|||
onPress={() => handleSelect(item.id)}
|
||||
>
|
||||
<Text style={[styles.name, { color: textColor }]}>{item.title}</Text>
|
||||
{selectedId === item.id && <Check size={18} color="#3b82f6" />}
|
||||
{selectedId === item.id && <Check size={18} color={PRIMARY} />}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
} from "react-native";
|
||||
import { X, Calendar } from "lucide-react-native";
|
||||
import DateTimePicker from "@react-native-community/datetimepicker";
|
||||
import { PRIMARY } from "@/constants/colors";
|
||||
import { useThemeColor } from "@/hooks/useThemeColor";
|
||||
import { useColorScheme } from "@/hooks/useColorScheme";
|
||||
import { Etape } from "@/types/tasks.types";
|
||||
|
|
@ -229,7 +230,7 @@ const styles = StyleSheet.create({
|
|||
},
|
||||
saveButton: {
|
||||
marginHorizontal: 16,
|
||||
backgroundColor: "#3b82f6",
|
||||
backgroundColor: PRIMARY,
|
||||
borderRadius: 12,
|
||||
paddingVertical: 14,
|
||||
alignItems: "center",
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import React, { useMemo, useState } from "react";
|
|||
import { View, Text, ScrollView, RefreshControl, StyleSheet, TouchableOpacity } from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import { Plus } from "lucide-react-native";
|
||||
import { PRIMARY } from "@/constants/colors";
|
||||
import { useThemeColor } from "@/hooks/useThemeColor";
|
||||
import { useColorScheme } from "@/hooks/useColorScheme";
|
||||
import { useTasksByTablo } from "@/hooks/tasks";
|
||||
import { useTabloEtapes, useDeleteEtape } from "@/hooks/etapes";
|
||||
import { Task, Etape, TASK_STATUSES, TaskStatus } from "@/types/tasks.types";
|
||||
|
|
@ -39,7 +39,7 @@ function groupTasksByEtape(tasks: Task[], etapes: Etape[]): GroupedTasks[] {
|
|||
return groups;
|
||||
}
|
||||
|
||||
function sortTasksByStatus(tasks: Task[]): { status: TaskStatus; label: string; color: string; tasks: Task[] }[] {
|
||||
function sortTasksByStatus(tasks: Task[]): { value: TaskStatus; label: string; color: string; tasks: Task[] }[] {
|
||||
return TASK_STATUSES
|
||||
.map((s) => ({
|
||||
...s,
|
||||
|
|
@ -49,8 +49,6 @@ function sortTasksByStatus(tasks: Task[]): { status: TaskStatus; label: string;
|
|||
}
|
||||
|
||||
export default function TaskList({ tabloId, onEditEtape, onCreateEtape }: TaskListProps) {
|
||||
const colorScheme = useColorScheme();
|
||||
const isDark = colorScheme === "dark";
|
||||
const textColor = useThemeColor({ light: "#1f2937", dark: "#f9fafb" }, "text");
|
||||
const subtextColor = useThemeColor({ light: "#6b7280", dark: "#9ca3af" }, "text");
|
||||
|
||||
|
|
@ -108,7 +106,7 @@ export default function TaskList({ tabloId, onEditEtape, onCreateEtape }: TaskLi
|
|||
>
|
||||
{/* Create etape button */}
|
||||
<TouchableOpacity style={styles.addEtapeButton} onPress={onCreateEtape}>
|
||||
<Plus size={16} color="#3b82f6" />
|
||||
<Plus size={16} color={PRIMARY} />
|
||||
<Text style={styles.addEtapeText}>Nouvelle étape</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
|
|
@ -179,7 +177,7 @@ const styles = StyleSheet.create({
|
|||
paddingVertical: 10,
|
||||
},
|
||||
addEtapeText: {
|
||||
color: "#3b82f6",
|
||||
color: PRIMARY,
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React from "react";
|
||||
import { View, Text, TouchableOpacity, StyleSheet } from "react-native";
|
||||
import { PRIMARY } from "@/constants/colors";
|
||||
import { Task, TASK_STATUSES } from "@/types/tasks.types";
|
||||
import { useThemeColor } from "@/hooks/useThemeColor";
|
||||
import { useColorScheme } from "@/hooks/useColorScheme";
|
||||
|
|
@ -102,7 +103,7 @@ const styles = StyleSheet.create({
|
|||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
backgroundColor: "#3b82f6",
|
||||
backgroundColor: PRIMARY,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
|
||||
import { SymbolWeight } from "expo-symbols";
|
||||
import React from "react";
|
||||
import { OpaqueColorValue, StyleProp, ViewStyle } from "react-native";
|
||||
import { OpaqueColorValue } from "react-native";
|
||||
|
||||
// Add your SFSymbol to MaterialIcons mappings here.
|
||||
const MAPPING = {
|
||||
|
|
@ -36,7 +36,7 @@ export function IconSymbol({
|
|||
name: IconSymbolName;
|
||||
size?: number;
|
||||
color: string | OpaqueColorValue;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
style?: React.ComponentProps<typeof MaterialIcons>["style"];
|
||||
weight?: SymbolWeight;
|
||||
}) {
|
||||
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;
|
||||
|
|
|
|||
|
|
@ -3,8 +3,14 @@
|
|||
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
|
||||
*/
|
||||
|
||||
const tintColorLight = "#0a7ea4";
|
||||
const tintColorDark = "#fff";
|
||||
export const PRIMARY = "#804EEC";
|
||||
export const PRIMARY_DARK = "#6f3fd4";
|
||||
export const PRIMARY_LIGHT = "#CDB8FF";
|
||||
export const PRIMARY_SURFACE = "#F4F3FF";
|
||||
export const PRIMARY_SURFACE_DARK = "#2F2548";
|
||||
|
||||
const tintColorLight = PRIMARY;
|
||||
const tintColorDark = PRIMARY_LIGHT;
|
||||
|
||||
export const Colors = {
|
||||
light: {
|
||||
|
|
@ -39,7 +45,7 @@ export const AVAILABLE_COLORS = [
|
|||
];
|
||||
|
||||
export const ColorMap: Record<(typeof AVAILABLE_COLORS)[number], string> = {
|
||||
"bg-blue-500": "#3b82f6",
|
||||
"bg-blue-500": PRIMARY,
|
||||
"bg-green-500": "#10b981",
|
||||
"bg-red-500": "#ef4444",
|
||||
"bg-yellow-500": "#f59e0b",
|
||||
|
|
|
|||
|
|
@ -3,8 +3,14 @@
|
|||
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
|
||||
*/
|
||||
|
||||
const tintColorLight = "#0a7ea4";
|
||||
const tintColorDark = "#fff";
|
||||
export const PRIMARY = "#804EEC";
|
||||
export const PRIMARY_DARK = "#6f3fd4";
|
||||
export const PRIMARY_LIGHT = "#CDB8FF";
|
||||
export const PRIMARY_SURFACE = "#F4F3FF";
|
||||
export const PRIMARY_SURFACE_DARK = "#2F2548";
|
||||
|
||||
const tintColorLight = PRIMARY;
|
||||
const tintColorDark = PRIMARY_LIGHT;
|
||||
|
||||
export const Colors = {
|
||||
light: {
|
||||
|
|
@ -39,7 +45,7 @@ export const AVAILABLE_COLORS = [
|
|||
];
|
||||
|
||||
export const ColorMap: Record<(typeof AVAILABLE_COLORS)[number], string> = {
|
||||
"bg-blue-500": "#3b82f6",
|
||||
"bg-blue-500": PRIMARY,
|
||||
"bg-green-500": "#10b981",
|
||||
"bg-red-500": "#ef4444",
|
||||
"bg-yellow-500": "#f59e0b",
|
||||
|
|
|
|||
10
xtablo-expo/eslint.config.js
Normal file
10
xtablo-expo/eslint.config.js
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
// https://docs.expo.dev/guides/using-eslint/
|
||||
const { defineConfig } = require('eslint/config');
|
||||
const expoConfig = require("eslint-config-expo/flat");
|
||||
|
||||
module.exports = defineConfig([
|
||||
expoConfig,
|
||||
{
|
||||
ignores: ["dist/*"],
|
||||
}
|
||||
]);
|
||||
67
xtablo-expo/features/chat/channelTimeline.test.ts
Normal file
67
xtablo-expo/features/chat/channelTimeline.test.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { describe, expect, it } from "@jest/globals";
|
||||
import {
|
||||
buildTimelineItems,
|
||||
formatDayLabel,
|
||||
type MessageGroup,
|
||||
} from "./channelTimeline";
|
||||
|
||||
const makeGroup = (key: string, createdAt: string): MessageGroup => ({
|
||||
key,
|
||||
userId: "user-1",
|
||||
isOwn: false,
|
||||
messages: [
|
||||
{
|
||||
id: key,
|
||||
userId: "user-1",
|
||||
text: "hello",
|
||||
createdAt,
|
||||
clientId: key,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe("buildTimelineItems", () => {
|
||||
it("inserts one separator for the first group of a day", () => {
|
||||
const items = buildTimelineItems(
|
||||
[
|
||||
makeGroup("a", "2026-04-19T08:00:00.000Z"),
|
||||
makeGroup("b", "2026-04-19T10:00:00.000Z"),
|
||||
],
|
||||
new Date("2026-04-19T12:00:00.000Z")
|
||||
);
|
||||
|
||||
expect(items.filter((item) => item.type === "day-separator")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("inserts a new separator when the day changes", () => {
|
||||
const items = buildTimelineItems(
|
||||
[
|
||||
makeGroup("a", "2026-04-18T12:00:00.000Z"),
|
||||
makeGroup("b", "2026-04-19T08:00:00.000Z"),
|
||||
],
|
||||
new Date("2026-04-19T12:00:00.000Z")
|
||||
);
|
||||
|
||||
expect(items.filter((item) => item.type === "day-separator")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDayLabel", () => {
|
||||
it("returns Aujourd'hui for the current day", () => {
|
||||
expect(
|
||||
formatDayLabel(
|
||||
new Date("2026-04-19T08:00:00.000Z"),
|
||||
new Date("2026-04-19T12:00:00.000Z")
|
||||
)
|
||||
).toBe("Aujourd'hui");
|
||||
});
|
||||
|
||||
it("returns Hier for the previous day", () => {
|
||||
expect(
|
||||
formatDayLabel(
|
||||
new Date("2026-04-18T08:00:00.000Z"),
|
||||
new Date("2026-04-19T12:00:00.000Z")
|
||||
)
|
||||
).toBe("Hier");
|
||||
});
|
||||
});
|
||||
85
xtablo-expo/features/chat/channelTimeline.ts
Normal file
85
xtablo-expo/features/chat/channelTimeline.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { ChatMessage } from "@/types/chat.types";
|
||||
|
||||
export type MessageGroup = {
|
||||
key: string;
|
||||
userId: string;
|
||||
messages: ChatMessage[];
|
||||
isOwn: boolean;
|
||||
};
|
||||
|
||||
export type TimelineItem =
|
||||
| { type: "day-separator"; key: string; label: string }
|
||||
| { type: "message-group"; key: string; group: MessageGroup };
|
||||
|
||||
function startOfDay(date: Date) {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
}
|
||||
|
||||
function getDayKey(date: Date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function isSameDay(left: Date, right: Date) {
|
||||
return (
|
||||
left.getFullYear() === right.getFullYear() &&
|
||||
left.getMonth() === right.getMonth() &&
|
||||
left.getDate() === right.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
export function formatDayLabel(date: Date, now: Date) {
|
||||
const day = startOfDay(date);
|
||||
const today = startOfDay(now);
|
||||
|
||||
if (isSameDay(day, today)) {
|
||||
return "Aujourd'hui";
|
||||
}
|
||||
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(today.getDate() - 1);
|
||||
|
||||
if (isSameDay(day, yesterday)) {
|
||||
return "Hier";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("fr-FR", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function buildTimelineItems(groups: MessageGroup[], now: Date) {
|
||||
const items: TimelineItem[] = [];
|
||||
let previousDayKey: string | null = null;
|
||||
|
||||
for (const group of groups) {
|
||||
const firstMessage = group.messages[0];
|
||||
if (!firstMessage) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const messageDate = new Date(firstMessage.createdAt);
|
||||
const dayKey = getDayKey(messageDate);
|
||||
|
||||
if (previousDayKey !== dayKey) {
|
||||
items.push({
|
||||
type: "day-separator",
|
||||
key: `day-${dayKey}`,
|
||||
label: formatDayLabel(messageDate, now),
|
||||
});
|
||||
previousDayKey = dayKey;
|
||||
}
|
||||
|
||||
items.push({
|
||||
type: "message-group",
|
||||
key: `group-${group.key}`,
|
||||
group,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
86
xtablo-expo/features/chat/messageAuthor.test.ts
Normal file
86
xtablo-expo/features/chat/messageAuthor.test.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { describe, expect, it } from "@jest/globals";
|
||||
import { getMessageAuthorDisplay } from "./messageAuthor";
|
||||
|
||||
describe("getMessageAuthorDisplay", () => {
|
||||
it("prefers channel member data for another participant", () => {
|
||||
const display = getMessageAuthorDisplay({
|
||||
userId: "member-1",
|
||||
members: {
|
||||
"member-1": {
|
||||
id: "member-1",
|
||||
name: "Alice Martin",
|
||||
avatar_url: "https://assets.xtablo.com/alice.jpg",
|
||||
},
|
||||
},
|
||||
currentUser: {
|
||||
id: "me",
|
||||
name: "Me",
|
||||
avatar_url: "https://assets.xtablo.com/me.jpg",
|
||||
},
|
||||
failedAvatarUserIds: {},
|
||||
});
|
||||
|
||||
expect(display).toEqual({
|
||||
name: "Alice Martin",
|
||||
avatarUrl: "https://assets.xtablo.com/alice.jpg",
|
||||
initial: "A",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the current user when the current member is not in the channel map", () => {
|
||||
const display = getMessageAuthorDisplay({
|
||||
userId: "me",
|
||||
members: {},
|
||||
currentUser: {
|
||||
id: "me",
|
||||
name: "Bob Owner",
|
||||
avatar_url: "https://assets.xtablo.com/bob.jpg",
|
||||
},
|
||||
failedAvatarUserIds: {},
|
||||
});
|
||||
|
||||
expect(display).toEqual({
|
||||
name: "Bob Owner",
|
||||
avatarUrl: "https://assets.xtablo.com/bob.jpg",
|
||||
initial: "B",
|
||||
});
|
||||
});
|
||||
|
||||
it("suppresses the avatar URL after an image failure has been recorded", () => {
|
||||
const display = getMessageAuthorDisplay({
|
||||
userId: "member-1",
|
||||
members: {
|
||||
"member-1": {
|
||||
id: "member-1",
|
||||
name: "Alice Martin",
|
||||
avatar_url: "https://assets.xtablo.com/alice.jpg",
|
||||
},
|
||||
},
|
||||
currentUser: null,
|
||||
failedAvatarUserIds: {
|
||||
"member-1": true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(display).toEqual({
|
||||
name: "Alice Martin",
|
||||
avatarUrl: null,
|
||||
initial: "A",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses safe fallbacks when sender data is incomplete", () => {
|
||||
const display = getMessageAuthorDisplay({
|
||||
userId: "missing",
|
||||
members: {},
|
||||
currentUser: null,
|
||||
failedAvatarUserIds: {},
|
||||
});
|
||||
|
||||
expect(display).toEqual({
|
||||
name: "Utilisateur",
|
||||
avatarUrl: null,
|
||||
initial: "U",
|
||||
});
|
||||
});
|
||||
});
|
||||
48
xtablo-expo/features/chat/messageAuthor.ts
Normal file
48
xtablo-expo/features/chat/messageAuthor.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
type ChatAuthorRecord = {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
avatar_url?: string | null;
|
||||
};
|
||||
|
||||
export type MessageAuthorDisplay = {
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
initial: string;
|
||||
};
|
||||
|
||||
type GetMessageAuthorDisplayParams = {
|
||||
userId: string;
|
||||
members: Record<string, ChatAuthorRecord | undefined>;
|
||||
currentUser: ChatAuthorRecord | null;
|
||||
failedAvatarUserIds: Record<string, boolean>;
|
||||
};
|
||||
|
||||
function firstNonEmpty(value?: string | null) {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
export function getMessageAuthorDisplay({
|
||||
userId,
|
||||
members,
|
||||
currentUser,
|
||||
failedAvatarUserIds,
|
||||
}: GetMessageAuthorDisplayParams): MessageAuthorDisplay {
|
||||
const member = members[userId];
|
||||
const currentUserMatch = currentUser?.id === userId ? currentUser : null;
|
||||
|
||||
const name =
|
||||
firstNonEmpty(member?.name) ??
|
||||
firstNonEmpty(currentUserMatch?.name) ??
|
||||
"Utilisateur";
|
||||
|
||||
const avatarUrl = failedAvatarUserIds[userId]
|
||||
? null
|
||||
: firstNonEmpty(member?.avatar_url) ?? firstNonEmpty(currentUserMatch?.avatar_url);
|
||||
|
||||
return {
|
||||
name,
|
||||
avatarUrl,
|
||||
initial: name.charAt(0).toUpperCase() || "U",
|
||||
};
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { EventAndTablo, EventInsert } from "@/types/events.types";
|
||||
import { Event, EventAndTablo, EventInsert } from "@/types/events.types";
|
||||
import { useUser } from "@/providers/UserProvider";
|
||||
|
||||
export const useEventsByTablo = (tabloId: string | null) => {
|
||||
|
|
|
|||
9356
xtablo-expo/package-lock.json
generated
9356
xtablo-expo/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -15,9 +15,9 @@
|
|||
"preset": "jest-expo"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^14.0.0",
|
||||
"@react-native-async-storage/async-storage": "2.1.2",
|
||||
"@react-native-community/datetimepicker": "8.4.1",
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-community/datetimepicker": "8.4.4",
|
||||
"@react-native-community/netinfo": "11.4.1",
|
||||
"@react-navigation/bottom-tabs": "^7.2.0",
|
||||
"@rn-vui/base": "^5.1.3",
|
||||
|
|
@ -25,41 +25,43 @@
|
|||
"@supabase/supabase-js": "^2.51.0",
|
||||
"@tanstack/react-query": "^5.75.2",
|
||||
"aes-js": "^3.1.2",
|
||||
"expo": "^53.0.19",
|
||||
"expo-apple-authentication": "~7.2.4",
|
||||
"expo-auth-session": "~6.2.1",
|
||||
"expo-av": "~15.1.7",
|
||||
"expo-blur": "~14.1.5",
|
||||
"expo-constants": "~17.1.5",
|
||||
"expo-crypto": "~14.1.5",
|
||||
"expo-dev-client": "~5.2.4",
|
||||
"expo-font": "~13.3.2",
|
||||
"expo-haptics": "~14.1.4",
|
||||
"expo-image-manipulator": "~13.1.7",
|
||||
"expo-image-picker": "~16.1.4",
|
||||
"expo-linear-gradient": "~14.1.5",
|
||||
"expo-linking": "~7.1.4",
|
||||
"expo-router": "~5.1.3",
|
||||
"expo-secure-store": "~14.2.3",
|
||||
"expo-splash-screen": "~0.30.8",
|
||||
"expo-status-bar": "~2.2.3",
|
||||
"expo-symbols": "~0.4.5",
|
||||
"expo-system-ui": "~5.0.10",
|
||||
"expo-web-browser": "~14.2.0",
|
||||
"axios": "^1.13.1",
|
||||
"expo": "^54.0.0",
|
||||
"expo-apple-authentication": "~8.0.8",
|
||||
"expo-auth-session": "~7.0.10",
|
||||
"expo-av": "~16.0.8",
|
||||
"expo-blur": "~15.0.8",
|
||||
"expo-constants": "~18.0.13",
|
||||
"expo-crypto": "~15.0.8",
|
||||
"expo-dev-client": "~6.0.20",
|
||||
"expo-font": "~14.0.11",
|
||||
"expo-haptics": "~15.0.8",
|
||||
"expo-image-manipulator": "~14.0.8",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
"expo-linear-gradient": "~15.0.8",
|
||||
"expo-linking": "~8.0.11",
|
||||
"expo-router": "~6.0.23",
|
||||
"expo-secure-store": "~15.0.8",
|
||||
"expo-splash-screen": "~31.0.13",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-symbols": "~1.0.8",
|
||||
"expo-system-ui": "~6.0.9",
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react-native": "^0.525.0",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react-native": "0.79.5",
|
||||
"react-native-gesture-handler": "~2.24.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-get-random-values": "~1.11.0",
|
||||
"react-native-reanimated": "~3.17.4",
|
||||
"react-native-safe-area-context": "5.4.0",
|
||||
"react-native-screens": "~4.11.1",
|
||||
"react-native-svg": "15.11.2",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "15.12.1",
|
||||
"react-native-url-polyfill": "^2.0.0",
|
||||
"react-native-web": "^0.20.0",
|
||||
"react-native-webview": "13.13.5",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-webview": "13.15.0",
|
||||
"react-native-worklets": "0.5.1",
|
||||
"zustand": "^5.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -68,10 +70,13 @@
|
|||
"@types/aes-js": "^3.1.4",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/lodash": "^4.17.13",
|
||||
"@types/react": "~19.0.10",
|
||||
"@types/react": "~19.1.10",
|
||||
"@types/react-test-renderer": "^19.1.0",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"jest": "^29.2.1",
|
||||
"jest-expo": "~53.0.9",
|
||||
"typescript": "^5.3.3"
|
||||
"jest-expo": "~54.0.17",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Database, Tables, TablesInsert, TablesUpdate } from "@/types/database.types";
|
||||
import { PRIMARY } from "@/constants/colors";
|
||||
import { RemoveNullFromObject } from "@/types/removeNull";
|
||||
|
||||
export type TaskStatus = Database["public"]["Enums"]["task_status"];
|
||||
|
|
@ -17,7 +18,7 @@ export type TaskInsert = TablesInsert<"tasks">;
|
|||
export type TaskUpdate = TablesUpdate<"tasks">;
|
||||
|
||||
export const TASK_STATUSES: { value: TaskStatus; label: string; color: string }[] = [
|
||||
{ value: "todo", label: "À faire", color: "#3b82f6" },
|
||||
{ value: "todo", label: "À faire", color: PRIMARY },
|
||||
{ value: "in_progress", label: "En cours", color: "#eab308" },
|
||||
{ value: "in_review", label: "Vérification", color: "#a855f7" },
|
||||
{ value: "done", label: "Terminé", color: "#22c55e" },
|
||||
|
|
|
|||
Loading…
Reference in a new issue