Changes to xtablo app

This commit is contained in:
Arthur Belleville 2026-04-29 15:45:31 +02:00
parent 07d61421b3
commit 9aa4953ae5
No known key found for this signature in database
37 changed files with 8665 additions and 2865 deletions

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

View 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

View file

@ -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 Aujourdhui for the current day", () => {
expect(
formatDayLabel(new Date("2026-04-19T08:00:00.000Z"), new Date("2026-04-19T12:00:00.000Z"))
).toBe("Aujourdhui");
});
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 screens shape)
- exported `TimelineItem` union type:
- `{ type: "day-separator"; key: string; label: string }`
- `{ type: "message-group"; key: string; group: MessageGroup }`
- `formatDayLabel(date, now)` helper returning:
- `Aujourdhui`
- `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 `Aujourdhui` 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"
```

View 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"
```

View 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

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

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

View file

@ -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) => {

File diff suppressed because it is too large Load diff

View file

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

View file

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