Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 08a20d29e5 | |||
| 9c384dfb96 | |||
| 4f384426d0 | |||
| 2fc1668356 | |||
| 0fb90de5a7 | |||
| 339d60d659 | |||
| fed2f32258 | |||
| 3378e1dce7 | |||
| d162ea762e | |||
| 6e817be13f |
+14
-6
@@ -1,8 +1,16 @@
|
|||||||
SERVER_IP=0.0.0.0
|
# Runtime env for the SvelteKit + Hono app.
|
||||||
PROXY_PORT=3456
|
#
|
||||||
FRONTEND_PORT=2080
|
# The app's own loopback URLs are constants in code (PROXY_URL / PB_ENDPOINT);
|
||||||
PB_PORT=8090
|
# ports live in config.ts for the proxy. Only these are real env vars:
|
||||||
|
|
||||||
|
# PB superuser (server-side only). Defaults in code: debug@famchamp.dev / debug123.
|
||||||
PB_EMAIL=
|
PB_EMAIL=
|
||||||
PB_PASSWORD=
|
PB_PASSWORD=
|
||||||
DEBUG_RECORD_ID=
|
# Public: the dev machine's IP where PB + the dev proxy run. Change this when
|
||||||
PUBLIC_PB_URL=
|
# your remote IP changes — the browser (pocketbase.ts) and pb-admin read it.
|
||||||
|
# Prod ignores this (uses /pb via nginx). Default: 192.168.1.225.
|
||||||
|
SERVER_IP=192.168.1.225
|
||||||
|
|
||||||
|
# docker-compose (staging) — host-side deploy config, never baked into the image.
|
||||||
|
PORT=3001 # public port to publish (nginx container listens on 3001)
|
||||||
|
PB_DATA=./pb_data # where to persist PocketBase data on the host
|
||||||
|
|||||||
+24
-12
@@ -243,6 +243,17 @@ All admin operations go through PB admin API (Hono proxy or `+page.server.ts`).
|
|||||||
- Audit trail option
|
- Audit trail option
|
||||||
- Consistent error handling
|
- Consistent error handling
|
||||||
|
|
||||||
|
### 5.5 Family Chat
|
||||||
|
|
||||||
|
Real-time right-slideout chat panel (TopNav chat icon, slideout on desktop / full-screen on mobile).
|
||||||
|
|
||||||
|
- **Writes go through the Hono proxy** (`POST /api/chat/:famId/messages`, `POST /api/chat/:famId/typing`) — the proxy resolves the actor via the `session` cookie / `x-session-*` headers (admin) or `x-device-token` headers (member). `GET /api/chat/me` returns the actor's identity (`{id, type, name, color}`) for both roles.
|
||||||
|
- **Reads use the anonymous PB SDK client-side** (`chatStore` in `frontend/src/lib/stores/chat.svelte.ts`), the same pattern as `famStore`. Collections `messages` and `chat_typing` have public `listRule`/`viewRule` (empty string); browser `.subscribe('*')` gives realtime SSE sync for both roles.
|
||||||
|
- **Collections:** `messages` (famId, authorType admin/member, authorId, authorName, authorColor, content, `createdAt`), `chat_typing` (transient per-actor presence: famId, actorId, actorType, authorName, authorColor, typing).
|
||||||
|
- **History** = last 14 days. Filter uses the custom `createdAt` field — the auto `created`/`updated` fields are NOT filterable in this PocketBase version (400), so a custom date field is set by the proxy at create time.
|
||||||
|
- **`createdAt` vs `created`:** all sorting, optimistic-temp messages, and the timestamp label use `createdAt`. The PB auto `created` field is not returned on records, so referencing it throws (`localeCompare` of undefined).
|
||||||
|
- **UI** (`Chat.svelte`): @mention tokens, typing indicators, unread badge, optimistic send with reconcile. The panel starts closed (no auto-open on mount) and is toggled via `chatStore.toggle()`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Project Structure
|
## 6. Project Structure
|
||||||
@@ -275,21 +286,22 @@ All admin operations go through PB admin API (Hono proxy or `+page.server.ts`).
|
|||||||
|
|
||||||
## 7. Environment Variables
|
## 7. Environment Variables
|
||||||
|
|
||||||
### Frontend + Hono container
|
### Current (this repo)
|
||||||
```
|
```
|
||||||
PUBLIC_PB_URL=https://pb.chores.app.com
|
# .env (dev; loaded by vite for the frontend + tsx --env-file for the proxy)
|
||||||
PB_ADMIN_EMAIL=admin@chores.app
|
PB_EMAIL=debug@famchamp.dev # PB superuser (server-only; pb-admin + proxy)
|
||||||
PB_ADMIN_PASSWORD=<super-admin-password>
|
PB_PASSWORD=debug123
|
||||||
STRIPE_SECRET_KEY=sk_live_xxx
|
SERVER_IP=192.168.1.225 # dev machine IP (Tailscale IP when away) — change when it changes
|
||||||
STRIPE_WEBHOOK_SECRET=whsec_xxx
|
|
||||||
DONATION_MODAL_INTERVAL=30
|
|
||||||
```
|
```
|
||||||
|
- Ports live in root `config.ts` (`FRONTEND_PORT`/`PROXY_PORT`/`PB_PORT` = `2080`/`3456`/`8090`) — the proxy reads them; SvelteKit never imports `config.ts`.
|
||||||
|
- SvelteKit env vars are declared in `frontend/src/env.ts` (`PROXY_URL`, `SERVER_IP`, `PB_EMAIL`, `PB_PASSWORD`) and read via `$app/env/public` / `$app/env/private`.
|
||||||
|
- Compose/deploy: `PORT` (public port, default `3001`), `PB_DATA` (host data dir). `PUBLIC_PB_URL` no longer exists (docker bakes `/pb`; browser PB URL derives from `SERVER_IP` in dev).
|
||||||
|
- Not yet wired: `STRIPE_SECRET_KEY`, `DONATION_MODAL_INTERVAL`.
|
||||||
|
|
||||||
### PocketBase container
|
### Dev vs Prod PocketBase data (⚠️)
|
||||||
```
|
- **Dev (`pnpm dev`)**: permanent dev PB = container **`pb-dev`** at `:8090`, data in host `./pb_data`. This is what the code talks to via `SERVER_IP:8090`.
|
||||||
PB_SUPERUSER_EMAIL=you@email.com
|
- **Prod/docker**: the app container bundles its **own internal PB**, published loopback-only at `127.0.0.1:8091`, and also bind-mounts `./pb_data`.
|
||||||
PB_SUPERUSER_PASSWORD=<your-password>
|
- Never recreate `pb-dev` with a fresh volume — restore it with `-v "$PWD/pb_data:/pb_data"` (full command in `RULES.md`).
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
├── [fam]/+page.svelte ← fam dashboard
|
├── [fam]/+page.svelte ← fam dashboard
|
||||||
├── [fam]/{username}/+page.svelte ← parent=admin overview, child=kanban
|
├── [fam]/{username}/+page.svelte ← parent=admin overview, child=kanban
|
||||||
├── [fam]/{username}/chores/+page.svelte ← parent only
|
├── [fam]/{username}/chores/+page.svelte ← parent only
|
||||||
├── [fam]/{username}/rewards/+page.svelte ← parent only
|
├── [fam]/{username}/ledger/+page.svelte ← parent only (rewards/chores/todos)
|
||||||
├── [fam]/{username}/bonuses/+page.svelte ← parent only
|
├── [fam]/{username}/bonuses/+page.svelte ← parent only
|
||||||
├── [fam]/{username}/settings/+page.svelte ← parent only
|
├── [fam]/{username}/settings/+page.svelte ← parent only
|
||||||
└── [fam]/{username}/preferences/+page.svelte ← both roles
|
└── [fam]/{username}/preferences/+page.svelte ← both roles
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
/{fam} Fam dashboard
|
/{fam} Fam dashboard
|
||||||
/{fam}/{username} Parent → admin overview, Child → kanban
|
/{fam}/{username} Parent → admin overview, Child → kanban
|
||||||
/{fam}/{username}/chores Parent: chore management
|
/{fam}/{username}/chores Parent: chore management
|
||||||
/{fam}/{username}/rewards Parent: reward ledger
|
/{fam}/{username}/ledger Parent: rewards / chores / todos ledger
|
||||||
/{fam}/{username}/bonuses Parent: bonus configs
|
/{fam}/{username}/bonuses Parent: bonus configs
|
||||||
/{fam}/{username}/settings Parent: family settings
|
/{fam}/{username}/settings Parent: family settings
|
||||||
/{fam}/{username}/preferences Both: edit name/color
|
/{fam}/{username}/preferences Both: edit name/color
|
||||||
@@ -159,3 +159,21 @@
|
|||||||
- **Check**: frontend `svelte-check` stays at 12 baseline errors. Note: frontend dev server on :2080 was not running when verified (proxy :3456 up).
|
- **Check**: frontend `svelte-check` stays at 12 baseline errors. Note: frontend dev server on :2080 was not running when verified (proxy :3456 up).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### 2026-08-06 — Env Consolidation: `SERVER_IP`, `PROXY_URL`, and SvelteKit env only
|
||||||
|
|
||||||
|
- **`config.ts` is proxy-only.** It now holds just the three ports (`FRONTEND_PORT`/`PROXY_PORT`/`PB_PORT` = `2080`/`3456`/`8090`). SvelteKit **never imports `config.ts`** — SvelteKit env vars are declared in `frontend/src/env.ts` and read via `$app/env/*`. Deleted the stale `config.js/.d.ts/.map` artifacts.
|
||||||
|
- **`frontend/src/env.ts`** declares: `PROXY_URL` (public, default `http://127.0.0.1:3456`), `SERVER_IP` (public, default `192.168.1.225`), `PB_EMAIL`/`PB_PASSWORD` (private, defaults). `PUBLIC_PB_URL` removed (was the source of a startup crash when unset).
|
||||||
|
- **Deleted `frontend/src/lib/server/env.ts`** (untracked). All server modules now `import { PROXY_URL } from '$app/env/public'` (`hono.ts`, `auth.ts`, `+layout.server.ts`, `+page.server.ts`, `preferences`, `join/[code]/[member]`). `admin/+page.server.ts` imports creds from `$app/env/private`.
|
||||||
|
- **`frontend/src/lib/pocketbase.ts` (browser) + `pb-admin.ts`**: `PB_ENDPOINT = import.meta.env.PROD ? '/pb' : \`http://${SERVER_IP}:8090\``. (Fixed a bug where `pocketbase.ts` used `import.meta.env.SERVER_IP` → undefined.)
|
||||||
|
- **`proxy/src/env.ts`** (new): `PB_ENDPOINT = SERVER_IP ? \`http://${SERVER_IP}:8090\` : \`http://127.0.0.1:8090\``. Dev env is loaded by the proxy's `dev`/`seed` scripts via `tsx --env-file-if-exists=../.env` (pnpm has no `--env-file`; `NODE_OPTIONS='--env-file=…'` is rejected by Node). No `loadEnvFile` hack in code.
|
||||||
|
- **Docker**: removed dead `ENV PB_ENDPOINT` from `Dockerfile`; `EXPOSE 3001` (was `3005 8090`); compose public port is `${PORT:-3001}:3001`, creds default to the code fallback, redundant `FRONTEND_PORT`/`PROXY_PORT` passthrough dropped; `entrypoint.sh` simplified (`PB_DATA=/app/pb_data`, `PORT=$FRONTEND_PORT`, no `:-` fallbacks).
|
||||||
|
- **Frontend deps added** (were missing imports): `chart.js`, `qrcode`, `@hiseb/confetti`.
|
||||||
|
- **Build checks**: proxy + frontend `pnpm build` clean.
|
||||||
|
|
||||||
|
### 2026-08-06 — Dev PB data incident (pb-dev) — see RULES.md "Dev vs Prod PocketBase data"
|
||||||
|
|
||||||
|
- **Symptom**: `pnpm dev` proxy migrate failed; PB superuser auth returned `HTTP 500 "Something went wrong"`; could not log into the PB admin UI.
|
||||||
|
- **Root cause**: the permanent dev PB container **`pb-dev`** (publishes `:8090`, data in host `./pb_data`) had a **broken bind mount** — it was serving an empty throwaway store, so the `debug@famchamp.dev` superuser didn't exist. The real `data.db` was on the host but the container wasn't seeing it.
|
||||||
|
- **Fix**: recreated `pb-dev` with the mount correctly attached (`-v "$PWD/pb_data:/pb_data"`, `pocketbase serve --http=0.0.0.0:8090 --dir=/pb_data`). Superuser auth then returned 200 on both `127.0.0.1:8090` and the Tailscale `SERVER_IP:8090`.
|
||||||
|
- **Watch-out**: a careless `docker run` with a **fresh volume** (my first attempt, aborted in time) would have wiped the permanent PB data. Restore command is in RULES.md. Two containers (`pb-dev` :8090 and the docker app's internal PB :8091) currently **share the same host `./pb_data`** — be careful with both.
|
||||||
|
|||||||
@@ -6,20 +6,37 @@
|
|||||||
- `deviceToken` stored as SHA-256 hash; never log raw tokens
|
- `deviceToken` stored as SHA-256 hash; never log raw tokens
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
- Use `.env` at root for runtime config values (ports, secrets). `.env.example` committed as template.
|
- **SvelteKit must never import `config.ts`.** `config.ts` at root owns the three service **ports** for the Hono proxy (`FRONTEND_PORT`/`PROXY_PORT`/`PB_PORT` = `2080`/`3456`/`8090`).
|
||||||
- `config.ts` at root for dev/build-time shared config (e.g. `PROXY_PORT`), reads from env with fallback defaults.
|
- SvelteKit env vars are declared in `frontend/src/env.ts` via `defineEnvVars`, read with `$app/env/public` / `$app/env/private`. Loopback URLs are code constants; the only real env vars are the PB creds and `SERVER_IP`.
|
||||||
- Never commit `.env` files (already in `.gitignore`).
|
- Runtime env values live in `.env` at root (symlinked to `frontend/.env`; loaded by the proxy dev via `tsx --env-file=../.env`). `.env.example` is the committed template. Never commit `.env`.
|
||||||
|
- `.env` currently holds only: `PB_EMAIL`, `PB_PASSWORD`, `SERVER_IP`. Ports come from `config.ts`, not `.env`.
|
||||||
|
|
||||||
## Ports
|
## Ports
|
||||||
- Frontend: `2080`
|
- Frontend: `2080`
|
||||||
- Proxy: `3456`
|
- Proxy: `3456`
|
||||||
- Container external: `3001` (port `3000` is reserved)
|
- Container external: `3001` (port `3000` is reserved) — chosen at deploy via compose `PORT`
|
||||||
- Must not use port `3000` for anything.
|
- Must not use port `3000` for anything.
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
- Prod: `docker/Dockerfile` (multi-stage + nginx)
|
- Prod: `docker/Dockerfile` (multi-stage + nginx, bundles internal PB)
|
||||||
- Dev: `docker/Dockerfile.dev` (PocketBase)
|
- Dev PB: `docker/Dockerfile.dev` (standalone PocketBase)
|
||||||
- Nginx routes: `/api/*` → Hono (`:3456`), `/*` → SvelteKit (`:2080`)
|
- Nginx routes: `/api/*` → Hono (`:3456`), `/pb/api/*` → PB (`:8090`), `/pb/` → 404 (admin UI kept internal), `/*` → SvelteKit (`:2080`)
|
||||||
|
- `docker-compose.yaml`: public port is `${PORT:-3001}:3001`; internal PB published loopback-only as `127.0.0.1:8091:8090`.
|
||||||
|
|
||||||
|
## ⚠️ Dev vs Prod PocketBase data (READ BEFORE TOUCHING CONTAINERS)
|
||||||
|
- **Permanent dev PB** = container **`pb-dev`**, publishes **`:8090`**, data in host **`./pb_data`** (root-owned). This is what `pnpm dev` talks to (via `SERVER_IP:8090`).
|
||||||
|
- **Docker app** (`famchamp-app-1`) has its **own internal PB**, published **`:8091`** (loopback), and it **also bind-mounts `./pb_data` → `/app/pb_data`**. So `pb-dev` and the docker app currently **share the same host data dir** — two PB processes on one store. Keep this in mind; the docker version is not the dev target.
|
||||||
|
- **NEVER `docker rm`/recreate `pb-dev` with a fresh volume** — that wipes the permanent dev PB. To restore it, recreate with its data intact:
|
||||||
|
```
|
||||||
|
docker rm -f pb-dev
|
||||||
|
docker run -d --name pb-dev \
|
||||||
|
-v "$PWD/pb_data:/pb_data" \
|
||||||
|
-p 8090:8090 \
|
||||||
|
pb-dev \
|
||||||
|
pocketbase serve --http=0.0.0.0:8090 --dir=/pb_data
|
||||||
|
```
|
||||||
|
- Superuser creds: `debug@famchamp.dev` / `debug123` (fallback baked in code). If superuser auth returns HTTP 500, the container is almost certainly serving an **empty store** (broken/missing mount) — restore the mount, don't reseed a new store.
|
||||||
|
- `SERVER_IP`: same LAN = `192.168.1.225`, away = your Tailscale IP (e.g. `100.103.22.104`). Both hit the same `pb-dev:8090`. Update `.env` when your IP changes.
|
||||||
|
|
||||||
## Monorepo
|
## Monorepo
|
||||||
- SvelteKit in `frontend/`, Hono in `proxy/`
|
- SvelteKit in `frontend/`, Hono in `proxy/`
|
||||||
@@ -27,8 +44,8 @@
|
|||||||
- Decisions tracked in `MEMORY.md`
|
- Decisions tracked in `MEMORY.md`
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
- `FRONTEND_PORT` — frontend server port
|
- `SERVER_IP` — dev machine IP where PB + proxy run (browser `pocketbase.ts`, `pb-admin`, proxy all read it). Dev only; prod ignores it.
|
||||||
- `PROXY_PORT` — Hono proxy port
|
- `PB_EMAIL` / `PB_PASSWORD` — PB superuser (fallback `debug@famchamp.dev`/`debug123`)
|
||||||
- `PB_URL`, `PB_ADMIN_EMAIL`, `PB_ADMIN_PASSWORD` — PocketBase admin
|
- `PROXY_URL` — public env in `env.ts` (default `http://127.0.0.1:3456`); SSR → proxy
|
||||||
- `STRIPE_SECRET_KEY` — Stripe
|
- Compose/deploy: `PORT` (public port, default `3001`), `PB_DATA` (host data dir)
|
||||||
- `DONATION_MODAL_INTERVAL` — donation modal frequency
|
- Not currently wired (future): `STRIPE_SECRET_KEY`, `DONATION_MODAL_INTERVAL`
|
||||||
|
|||||||
Vendored
-9
@@ -1,9 +0,0 @@
|
|||||||
export declare const FRONTEND_PORT = "2080";
|
|
||||||
export declare const PROXY_PORT = "3456";
|
|
||||||
export declare const SERVER_IP = "192.168.1.225";
|
|
||||||
export declare const PB_PORT = "8090";
|
|
||||||
export declare const PB_EMAIL = "debug@famchamp.dev";
|
|
||||||
export declare const PB_PASSWORD = "debug123";
|
|
||||||
export declare const DEBUG_RECORD_ID = "0747qjl16m6o529";
|
|
||||||
export declare const PUBLIC_PB_URL = "http://192.168.1.225:8090";
|
|
||||||
//# sourceMappingURL=config.d.ts.map
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["config.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,aAAa,SAAS,CAAC;AACpC,eAAO,MAAM,UAAU,SAAS,CAAC;AACjC,eAAO,MAAM,SAAS,kBAAkB,CAAC;AACzC,eAAO,MAAM,OAAO,SAAS,CAAC;AAC9B,eAAO,MAAM,QAAQ,uBAAuB,CAAC;AAC7C,eAAO,MAAM,WAAW,aAAa,CAAC;AACtC,eAAO,MAAM,eAAe,oBAAoB,CAAC;AACjD,eAAO,MAAM,aAAa,8BAAmC,CAAC"}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
export const FRONTEND_PORT = "2080";
|
|
||||||
export const PROXY_PORT = "3456";
|
|
||||||
export const SERVER_IP = "192.168.1.225";
|
|
||||||
export const PB_PORT = "8090";
|
|
||||||
export const PB_EMAIL = "debug@famchamp.dev";
|
|
||||||
export const PB_PASSWORD = "debug123";
|
|
||||||
export const DEBUG_RECORD_ID = "0747qjl16m6o529";
|
|
||||||
export const PUBLIC_PB_URL = `http://${SERVER_IP}:${PB_PORT}`;
|
|
||||||
//# sourceMappingURL=config.js.map
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"version":3,"file":"config.js","sourceRoot":"","sources":["config.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,aAAa,GAAG,MAAM,CAAC;AACpC,MAAM,CAAC,MAAM,UAAU,GAAG,MAAM,CAAC;AACjC,MAAM,CAAC,MAAM,SAAS,GAAG,eAAe,CAAC;AACzC,MAAM,CAAC,MAAM,OAAO,GAAG,MAAM,CAAC;AAC9B,MAAM,CAAC,MAAM,QAAQ,GAAG,oBAAoB,CAAC;AAC7C,MAAM,CAAC,MAAM,WAAW,GAAG,UAAU,CAAC;AACtC,MAAM,CAAC,MAAM,eAAe,GAAG,iBAAiB,CAAC;AACjD,MAAM,CAAC,MAAM,aAAa,GAAG,UAAU,SAAS,IAAI,OAAO,EAAE,CAAC"}
|
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
|
// Single source of truth for the three service ports (dev/build-time only,
|
||||||
|
// used by the Hono proxy). Runtime URLs are set via env (see proxy/src/env.ts).
|
||||||
export const FRONTEND_PORT = "2080";
|
export const FRONTEND_PORT = "2080";
|
||||||
export const PROXY_PORT = "3456";
|
export const PROXY_PORT = "3456";
|
||||||
export const SERVER_IP = "192.168.1.225";
|
|
||||||
export const PB_PORT = "8090";
|
export const PB_PORT = "8090";
|
||||||
export const PB_EMAIL = "debug@famchamp.dev";
|
|
||||||
export const PB_PASSWORD = "debug123";
|
|
||||||
export const DEBUG_RECORD_ID = "0747qjl16m6o529";
|
|
||||||
export const PUBLIC_PB_URL = `http://${SERVER_IP}:${PB_PORT}`;
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/Dockerfile
|
||||||
|
ports:
|
||||||
|
- "3001:80" # nginx / app (public)
|
||||||
|
- "8091:8090" # PocketBase admin UI (loopback only — SSH tunnel)
|
||||||
|
volumes:
|
||||||
|
- "./pb_data:/app/pb_data" # PB DB persistence
|
||||||
|
environment:
|
||||||
|
PB_EMAIL: debug@famchamp.dev
|
||||||
|
PB_PASSWORD: debug123
|
||||||
|
restart: unless-stopped
|
||||||
+19
-5
@@ -1,4 +1,7 @@
|
|||||||
|
# ── Build stage ──────────────────────────────────────────
|
||||||
FROM node:22-alpine AS builder
|
FROM node:22-alpine AS builder
|
||||||
|
# Browser → PB goes through nginx at /pb (same origin) — fixed for docker.
|
||||||
|
# ENV PUBLIC_PB_URL=/pb
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -6,8 +9,12 @@ RUN pnpm install --frozen-lockfile
|
|||||||
RUN pnpm --filter frontend build
|
RUN pnpm --filter frontend build
|
||||||
RUN pnpm --filter proxy build
|
RUN pnpm --filter proxy build
|
||||||
|
|
||||||
|
# ── Runtime stage ────────────────────────────────────────
|
||||||
FROM node:22-alpine
|
FROM node:22-alpine
|
||||||
RUN corepack enable && apk add --no-cache nginx
|
# Browser → PB goes through nginx at /pb (same origin). PB_ENDPOINT (server →
|
||||||
|
# PB) is a loopback constant in the app code, not an env var.
|
||||||
|
# ENV PUBLIC_PB_URL=/pb
|
||||||
|
RUN corepack enable && apk add --no-cache nginx wget unzip ca-certificates
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=builder /app/frontend/build ./frontend/build
|
COPY --from=builder /app/frontend/build ./frontend/build
|
||||||
COPY --from=builder /app/proxy/dist ./proxy/dist
|
COPY --from=builder /app/proxy/dist ./proxy/dist
|
||||||
@@ -15,8 +22,15 @@ COPY --from=builder /app/node_modules ./node_modules
|
|||||||
COPY --from=builder /app/pnpm-lock.yaml ./
|
COPY --from=builder /app/pnpm-lock.yaml ./
|
||||||
COPY docker/nginx.conf /etc/nginx/http.d/default.conf
|
COPY docker/nginx.conf /etc/nginx/http.d/default.conf
|
||||||
COPY docker/entrypoint.sh /entrypoint.sh
|
COPY docker/entrypoint.sh /entrypoint.sh
|
||||||
RUN chmod +x /entrypoint.sh
|
# Bundle PocketBase (same version as Dockerfile.dev) into the app container.
|
||||||
ENV FRONTEND_PORT=2080
|
RUN wget -qO /tmp/pb.zip https://github.com/pocketbase/pocketbase/releases/download/v0.25.8/pocketbase_0.25.8_linux_amd64.zip \
|
||||||
ENV PROXY_PORT=3456
|
&& unzip -o /tmp/pb.zip -d /usr/local/bin/ \
|
||||||
EXPOSE 3001
|
&& rm /tmp/pb.zip
|
||||||
|
RUN chmod +x /entrypoint.sh /usr/local/bin/pocketbase
|
||||||
|
# ENV FRONTEND_PORT=2080
|
||||||
|
# ENV PROXY_PORT=3456
|
||||||
|
# Public entrypoint. The host port is chosen at deploy time via compose PORT;
|
||||||
|
# EXPOSE here is documentation only (nginx listens on 3001). PB (8090) stays
|
||||||
|
# internal — never published from the image.
|
||||||
|
EXPOSE 3000 8090
|
||||||
CMD ["/entrypoint.sh"]
|
CMD ["/entrypoint.sh"]
|
||||||
|
|||||||
+28
-2
@@ -1,7 +1,33 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
PORT=${FRONTEND_PORT:-2080} node /app/frontend/build/index.js &
|
# PocketBase data lives on the mounted volume (compose maps ./pb_data → /app/pb_data).
|
||||||
PROXY_PORT=${PROXY_PORT:-3456} node /app/proxy/dist/index.js &
|
PB_DATA=/app/pb_data
|
||||||
|
|
||||||
|
# Seed a platform superuser from env (idempotent — no-op if it already exists).
|
||||||
|
# If PB_EMAIL/PB_PASSWORD aren't set, skip and rely on manual web setup.
|
||||||
|
if [ -n "$PB_EMAIL" ] && [ -n "$PB_PASSWORD" ]; then
|
||||||
|
echo "[entrypoint] Ensuring PocketBase superuser..."
|
||||||
|
pocketbase superuser upsert "$PB_EMAIL" "$PB_PASSWORD" --dir="$PB_DATA" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start PocketBase (internal only; published via loopback for admin UI).
|
||||||
|
pocketbase serve --http=0.0.0.0:8090 --dir="$PB_DATA" &
|
||||||
|
|
||||||
|
# Wait for PB to be healthy before starting the proxy (which runs migrate).
|
||||||
|
echo "[entrypoint] Waiting for PocketBase..."
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if curl -sf http://127.0.0.1:8090/api/health >/dev/null 2>&1; then
|
||||||
|
echo "[entrypoint] PocketBase is healthy."
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
# Start the app (frontend + proxy). The proxy auto-runs schema migration.
|
||||||
|
# FRONTEND_PORT/PROXY_PORT are set via ENV in the Dockerfile; adapter-node
|
||||||
|
# reads PORT, the proxy reads PROXY_PORT.
|
||||||
|
node /app/frontend/build/index.js &
|
||||||
|
node /app/proxy/dist/index.js &
|
||||||
|
|
||||||
nginx -g 'daemon off;'
|
nginx -g 'daemon off;'
|
||||||
|
|||||||
+24
-2
@@ -1,9 +1,9 @@
|
|||||||
server {
|
server {
|
||||||
listen 3001;
|
listen 80;
|
||||||
server_name _;
|
server_name _;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:2080;
|
proxy_pass http://127.0.0.1:3000;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection 'upgrade';
|
proxy_set_header Connection 'upgrade';
|
||||||
@@ -11,6 +11,7 @@ server {
|
|||||||
proxy_cache_bypass $http_upgrade;
|
proxy_cache_bypass $http_upgrade;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# App's Hono proxy (/api/*).
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://127.0.0.1:3456;
|
proxy_pass http://127.0.0.1:3456;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -19,4 +20,25 @@ server {
|
|||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Browser → internal PocketBase SDK (REST + realtime WebSocket). Only the
|
||||||
|
# /api subtree the PocketBase JS SDK uses. The admin UI (/_ and everything
|
||||||
|
# else under /pb/) is intentionally NOT proxied, keeping it internal.
|
||||||
|
location /pb/api/ {
|
||||||
|
proxy_pass http://127.0.0.1:8090/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Everything else under /pb/ (including /pb/_ admin UI) → 404.
|
||||||
|
location /pb/ {
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-alert-circle"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
|
||||||
|
After Width: | Height: | Size: 356 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-alert-triangle"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>
|
||||||
|
After Width: | Height: | Size: 424 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-chevron-down"><polyline points="6 9 12 15 18 9"></polyline></svg>
|
||||||
|
After Width: | Height: | Size: 269 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-chevron-up"><polyline points="18 15 12 9 6 15"></polyline></svg>
|
||||||
|
After Width: | Height: | Size: 268 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-message-square"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 305 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-trash-2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg>
|
||||||
|
After Width: | Height: | Size: 448 B |
@@ -29,6 +29,9 @@
|
|||||||
"vite": "8.0.16"
|
"vite": "8.0.16"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pocketbase": "^0.27.0"
|
"@hiseb/confetti": "^2.0.2",
|
||||||
|
"chart.js": "^4.4.0",
|
||||||
|
"pocketbase": "^0.27.0",
|
||||||
|
"qrcode": "^1.5.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-4
@@ -1,8 +1,19 @@
|
|||||||
import { defineEnvVars } from '@sveltejs/kit/hooks';
|
import { defineEnvVars } from '@sveltejs/kit/hooks';
|
||||||
|
|
||||||
|
// Default when the env var isn't set, so a missing value never crashes startup.
|
||||||
|
const withDefault = (value: string) => ({
|
||||||
|
'~standard': {
|
||||||
|
version: 1,
|
||||||
|
vendor: 'famchamp',
|
||||||
|
validate: (v: unknown) => ({ value: typeof v === 'string' && v ? v : value })
|
||||||
|
}
|
||||||
|
} as const);
|
||||||
|
|
||||||
export const variables = defineEnvVars({
|
export const variables = defineEnvVars({
|
||||||
DEBUG_RECORD_ID: {},
|
// SSR → Hono proxy (loopback, proxy runs on the same host as SSR).
|
||||||
SERVER_IP: {public: true},
|
PROXY_URL: { public: true, schema: withDefault('http://127.0.0.1:3456') },
|
||||||
PB_PORT: {public: true},
|
SERVER_IP: { public: true, schema: withDefault('192.168.1.225') },
|
||||||
PUBLIC_PB_URL: {public: true}
|
// PB superuser creds (server-only).
|
||||||
|
PB_EMAIL: { public: false, schema: withDefault('debug@famchamp.dev') },
|
||||||
|
PB_PASSWORD: { public: false, schema: withDefault('debug123') }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Handle } from '@sveltejs/kit';
|
import type { Handle } from '@sveltejs/kit';
|
||||||
|
|
||||||
const COOKIE_NAME = 'session';
|
const COOKIE_NAME = 'famdon-sesh';
|
||||||
|
|
||||||
export const handle: Handle = async ({ event, resolve }) => {
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
const raw = event.cookies.get(COOKIE_NAME);
|
const raw = event.cookies.get(COOKIE_NAME);
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { SERVER_IP, PROXY_PORT } from '../../../../config.ts';
|
// Client-only. All /api calls go same-origin (vite proxy in dev, nginx in
|
||||||
|
// prod). Server-side (SSR) calls use PROXY_URL from $app/env/public instead.
|
||||||
const BASE_URL = typeof window === 'undefined'
|
const BASE_URL = '';
|
||||||
? `http://${SERVER_IP}:${PROXY_PORT}`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
async function memberFetch<T = unknown>(
|
async function memberFetch<T = unknown>(
|
||||||
method: string,
|
method: string,
|
||||||
|
|||||||
@@ -0,0 +1,353 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { chatStore } from '$lib/stores/chat.svelte';
|
||||||
|
import { chatIcon, sendIcon } from './icons';
|
||||||
|
import { formatWeekday, formatHumanDate } from '$lib/format';
|
||||||
|
|
||||||
|
let { role = 'child' }: { role?: string } = $props();
|
||||||
|
|
||||||
|
let draft = $state('');
|
||||||
|
let sending = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
let container: HTMLDivElement | undefined = $state();
|
||||||
|
let idleTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
const isOwn = (msg: any) => msg.authorId === chatStore.actorId;
|
||||||
|
|
||||||
|
function scrollToBottom(instant = false) {
|
||||||
|
if (!container) return;
|
||||||
|
container.scrollTo({
|
||||||
|
top: container.scrollHeight,
|
||||||
|
behavior: instant ? 'auto' : 'smooth'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const n = chatStore.messages.length;
|
||||||
|
if (n > 0) {
|
||||||
|
const last = chatStore.messages[n - 1];
|
||||||
|
// Only auto-scroll when the newest message is ours, or on open.
|
||||||
|
if (isOwn(last) || chatStore.open) scrollToBottom(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function send() {
|
||||||
|
const text = draft.trim();
|
||||||
|
if (!text || sending) return;
|
||||||
|
sending = true;
|
||||||
|
error = '';
|
||||||
|
draft = '';
|
||||||
|
clearIdle();
|
||||||
|
chatStore.setTyping(false).catch(() => {});
|
||||||
|
try {
|
||||||
|
await chatStore.send(text);
|
||||||
|
scrollToBottom(true);
|
||||||
|
} catch {
|
||||||
|
error = 'Could not send. Try again.';
|
||||||
|
} finally {
|
||||||
|
sending = false;
|
||||||
|
requestAnimationFrame(() => scrollToBottom(true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInput() {
|
||||||
|
chatStore.setTyping(true).catch(() => {});
|
||||||
|
clearIdle();
|
||||||
|
idleTimer = setTimeout(() => {
|
||||||
|
chatStore.setTyping(false).catch(() => {});
|
||||||
|
}, 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearIdle() {
|
||||||
|
if (idleTimer) clearTimeout(idleTimer);
|
||||||
|
idleTimer = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClose() {
|
||||||
|
clearIdle();
|
||||||
|
chatStore.setTyping(false).catch(() => {});
|
||||||
|
chatStore.closeChat();
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertMention(name: string) {
|
||||||
|
draft = draft.trimEnd();
|
||||||
|
draft = (draft ? draft + ' ' : '') + '@' + name + ' ';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render content, wrapping @mentions in a styled token.
|
||||||
|
function renderContent(text: string) {
|
||||||
|
const segs: { text: string; mention: boolean }[] = [];
|
||||||
|
const re = /(@[A-Za-z0-9_.-]+)/g;
|
||||||
|
let last = 0;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = re.exec(text)) !== null) {
|
||||||
|
if (m.index > last) segs.push({ text: text.slice(last, m.index), mention: false });
|
||||||
|
segs.push({ text: m[1], mention: true });
|
||||||
|
last = m.index + m[0].length;
|
||||||
|
}
|
||||||
|
if (last < text.length) segs.push({ text: text.slice(last), mention: false });
|
||||||
|
return segs;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
requestAnimationFrame(() => scrollToBottom(true));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="chat-panel" class:open={chatStore.open}>
|
||||||
|
<div class="chat-head">
|
||||||
|
<span class="chat-title">Family Chat</span>
|
||||||
|
<button class="chat-close" onclick={onClose} aria-label="Close chat">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chat-messages" bind:this={container}>
|
||||||
|
{#if chatStore.messages.length === 0}
|
||||||
|
<p class="chat-empty">No messages yet. Say hi! 👋</p>
|
||||||
|
{:else}
|
||||||
|
{#each chatStore.messages as msg (msg.id)}
|
||||||
|
<div class="msg-row" class:own={isOwn(msg)}>
|
||||||
|
{#if !isOwn(msg)}
|
||||||
|
<span class="avatar" style="background:{msg.authorColor || '#6366f1'}">
|
||||||
|
{msg.authorName?.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<div class="bubble-wrap">
|
||||||
|
{#if !isOwn(msg)}
|
||||||
|
<span class="msg-author">{msg.authorName}</span>
|
||||||
|
{/if}
|
||||||
|
<div class="bubble">
|
||||||
|
{#each renderContent(msg.content) as seg (msg.id + ':' + seg.text)}
|
||||||
|
{#if seg.mention}
|
||||||
|
<button class="mention" onclick={() => insertMention(seg.text.slice(1))}
|
||||||
|
>{seg.text}</button
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
{seg.text}
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
<span class="msg-time">{formatWeekday(msg.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chat-typing">
|
||||||
|
{#if chatStore.typingNames.length > 0}
|
||||||
|
<span class="typing-text">
|
||||||
|
{chatStore.typingNames.join(', ')}
|
||||||
|
{chatStore.typingNames.length === 1 ? 'is' : 'are'} typing…
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="typing-text"></span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chat-composer">
|
||||||
|
{#if error}<div class="chat-error">{error}</div>{/if}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="chat-input"
|
||||||
|
placeholder="Message the family…"
|
||||||
|
bind:value={draft}
|
||||||
|
oninput={onInput}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
send();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={sending}
|
||||||
|
/>
|
||||||
|
<button class="chat-send" onclick={send} disabled={sending || !draft.trim()} aria-label="Send">
|
||||||
|
{@html sendIcon}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.chat-panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 100dvh;
|
||||||
|
width: 500px;
|
||||||
|
background: #fff;
|
||||||
|
border-left: 1px solid #e5e7eb;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
z-index: 110;
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
box-shadow: -8px 0 24px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
.chat-panel.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
/* Mobile: full-screen fixed layer. */
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.chat-panel {
|
||||||
|
width: 100vw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.chat-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.85rem 1.25rem;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
background: #4338ca;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.chat-title {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.chat-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #c7d2fe;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
}
|
||||||
|
.chat-close:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.chat-messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.chat-empty {
|
||||||
|
color: #9ca3af;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 3rem;
|
||||||
|
}
|
||||||
|
.msg-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.msg-row.own {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.avatar {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.bubble-wrap {
|
||||||
|
max-width: 72%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.msg-row.own .bubble-wrap {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
.msg-author {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 0 0 0.15rem 0.35rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.bubble {
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 14px;
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #111827;
|
||||||
|
line-height: 1.4;
|
||||||
|
word-break: break-word;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.msg-row.own .bubble {
|
||||||
|
background: #6366f1;
|
||||||
|
color: #fff;
|
||||||
|
border-bottom-left-radius: 14px;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
}
|
||||||
|
.msg-time {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
text-align: right;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
.msg-row.own .msg-time {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
.mention {
|
||||||
|
background: #ede9fe;
|
||||||
|
color: #6d28d9;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.chat-typing {
|
||||||
|
min-height: 1.5rem;
|
||||||
|
padding: 0 1.25rem;
|
||||||
|
}
|
||||||
|
.typing-text {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.chat-composer {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1.25rem 1rem;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.chat-error {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 4.5rem;
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.chat-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.chat-input:focus {
|
||||||
|
border-color: #6366f1;
|
||||||
|
}
|
||||||
|
.chat-send {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: #6366f1;
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.chat-send:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { dashboardIcon, choresIcon, rewardsIcon, bonusesIcon, homeIcon, settingsIcon, logoutIcon, prefsIcon } from './icons';
|
import {
|
||||||
|
dashboardIcon,
|
||||||
|
choresIcon,
|
||||||
|
rewardsIcon,
|
||||||
|
bonusesIcon,
|
||||||
|
homeIcon,
|
||||||
|
settingsIcon,
|
||||||
|
logoutIcon,
|
||||||
|
prefsIcon
|
||||||
|
} from './icons';
|
||||||
|
|
||||||
let { famName = '', session = null, isParent = false, role = 'child' } = $props();
|
let { famName = '', session = null, isParent = false, role = 'child' } = $props();
|
||||||
|
|
||||||
@@ -9,30 +18,41 @@
|
|||||||
let famSlug = $derived(page.params.fam);
|
let famSlug = $derived(page.params.fam);
|
||||||
let memberName = $derived(session?.memberName || page.params.username || '');
|
let memberName = $derived(session?.memberName || page.params.username || '');
|
||||||
|
|
||||||
function toggle() { collapsed = !collapsed; }
|
function toggle() {
|
||||||
|
collapsed = !collapsed;
|
||||||
|
}
|
||||||
|
|
||||||
let showChildItems = $derived(!!memberName);
|
let showChildItems = $derived(!!memberName);
|
||||||
|
|
||||||
let navItems = $derived(isParent && memberName
|
let navItems = $derived(
|
||||||
|
isParent && memberName
|
||||||
? [
|
? [
|
||||||
{ href: `/${famSlug}`, label: famName, icon: homeIcon },
|
{ href: `/${famSlug}`, label: famName, icon: homeIcon },
|
||||||
{ href: `/${famSlug}/${memberName}`, label: 'Dashboard', icon: dashboardIcon },
|
{ href: `/${famSlug}/${memberName}`, label: 'Dashboard', icon: dashboardIcon },
|
||||||
{ href: `/${famSlug}/${memberName}/chores`, label: 'Chores', icon: choresIcon },
|
{ href: `/${famSlug}/${memberName}/chores`, label: 'Chores', icon: choresIcon },
|
||||||
{ href: `/${famSlug}/${memberName}/rewards`, label: 'Rewards', icon: rewardsIcon },
|
{ href: `/${famSlug}/${memberName}/ledger`, label: 'Ledger', icon: rewardsIcon },
|
||||||
{ href: `/${famSlug}/${memberName}/bonuses`, label: 'Bonuses', icon: bonusesIcon },
|
{ href: `/${famSlug}/${memberName}/bonuses`, label: 'Bonuses', icon: bonusesIcon }
|
||||||
]
|
]
|
||||||
: showChildItems
|
: showChildItems
|
||||||
? [
|
? [
|
||||||
{ href: `/${famSlug}`, label: famName, icon: homeIcon },
|
{ href: `/${famSlug}`, label: famName, icon: homeIcon },
|
||||||
{ href: `/${famSlug}/${memberName}`, label: 'Dashboard', icon: dashboardIcon },
|
{ href: `/${famSlug}/${memberName}`, label: 'Dashboard', icon: dashboardIcon }
|
||||||
]
|
]
|
||||||
: []
|
: []
|
||||||
);
|
);
|
||||||
|
|
||||||
let footerItems = $derived([
|
let footerItems = $derived([
|
||||||
...(memberName ? [{ href: `/${famSlug}/${memberName}/preferences`, label: 'Preferences', icon: prefsIcon }] : []),
|
...(memberName
|
||||||
...(isParent && memberName ? [{ href: `/${famSlug}/${memberName}/settings`, label: 'Settings', icon: settingsIcon }] : []),
|
? [{ href: `/${famSlug}/${memberName}/preferences`, label: 'Preferences', icon: prefsIcon }]
|
||||||
{ href: session ? '/logout' : '/login', label: session ? 'Log out' : 'Log in', icon: logoutIcon },
|
: []),
|
||||||
|
...(isParent && memberName
|
||||||
|
? [{ href: `/${famSlug}/${memberName}/settings`, label: 'Settings', icon: settingsIcon }]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
href: session ? '/logout' : '/login',
|
||||||
|
label: session ? 'Log out' : 'Log in',
|
||||||
|
icon: logoutIcon
|
||||||
|
}
|
||||||
]);
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -68,7 +88,8 @@
|
|||||||
<style>
|
<style>
|
||||||
.sidebar {
|
.sidebar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0; left: 0;
|
top: 0;
|
||||||
|
left: 0;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
width: 220px;
|
width: 220px;
|
||||||
background: #1e1b4b;
|
background: #1e1b4b;
|
||||||
@@ -79,10 +100,13 @@
|
|||||||
z-index: 100;
|
z-index: 100;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.sidebar.collapsed { width: 56px; }
|
.sidebar.collapsed {
|
||||||
|
width: 56px;
|
||||||
|
}
|
||||||
.toggle-btn {
|
.toggle-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.5rem; right: 0.5rem;
|
top: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: #a5b4fc;
|
color: #a5b4fc;
|
||||||
@@ -99,8 +123,15 @@
|
|||||||
border-bottom: 1px solid #3730a3;
|
border-bottom: 1px solid #3730a3;
|
||||||
min-height: 52px;
|
min-height: 52px;
|
||||||
}
|
}
|
||||||
.app-icon { font-size: 1.3rem; flex-shrink: 0; }
|
.app-icon {
|
||||||
.app-name { font-weight: 700; font-size: 1.05rem; white-space: nowrap; }
|
font-size: 1.3rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.app-name {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
.sidebar-nav {
|
.sidebar-nav {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 0.5rem 0;
|
padding: 0.5rem 0;
|
||||||
@@ -128,7 +159,16 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
.nav-item:hover { background: #3730a3; color: #e0e7ff; }
|
.nav-item:hover {
|
||||||
.nav-item.active { background: #4338ca; color: #fff; font-weight: 600; }
|
background: #3730a3;
|
||||||
.nav-label { overflow: hidden; }
|
color: #e0e7ff;
|
||||||
|
}
|
||||||
|
.nav-item.active {
|
||||||
|
background: #4338ca;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.nav-label {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -9,3 +9,5 @@ export const prefsIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="
|
|||||||
export const chevronLeft = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>'
|
export const chevronLeft = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>'
|
||||||
export const chevronRight = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>'
|
export const chevronRight = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>'
|
||||||
export const bellIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 01-3.46 0"/></svg>'
|
export const bellIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 01-3.46 0"/></svg>'
|
||||||
|
export const chatIcon = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>'
|
||||||
|
export const sendIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>'
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ export { default as Card } from './Card.svelte';
|
|||||||
export { default as CardGrid } from './CardGrid.svelte';
|
export { default as CardGrid } from './CardGrid.svelte';
|
||||||
export { default as Button } from './Button.svelte';
|
export { default as Button } from './Button.svelte';
|
||||||
export { default as Accordion } from './Accordion.svelte';
|
export { default as Accordion } from './Accordion.svelte';
|
||||||
|
export { default as Chat } from './Chat.svelte';
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// Two date display views used across the app:
|
||||||
|
// - Data view: `08-08-26` (dashed DD-MM-YY) — compact, tabular-friendly.
|
||||||
|
// - Human view: weekday in a badge (`Thursday`) with month/year added per requirement.
|
||||||
|
|
||||||
|
// Format a YYYY-MM-DD (or ISO) string as a compact dashed data-view date.
|
||||||
|
export function formatDDMMYY(dateStr: string | undefined): string {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
const d = new Date(dateStr.slice(0, 10) + 'T00:00:00');
|
||||||
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
const mon = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const yr = String(d.getFullYear()).slice(2);
|
||||||
|
return `${day}-${mon}-${yr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Human view — the weekday name ("Thursday"). Render this inside a badge.
|
||||||
|
export function formatWeekday(dateStr: string | undefined): string {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
const d = new Date(dateStr.slice(0, 10) + 'T00:00:00');
|
||||||
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
|
return d.toLocaleDateString('en-GB', { weekday: 'long' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Human view — weekday + short date ("Thursday 7 Aug", adds year when not the
|
||||||
|
// current one, e.g. "Thursday 7 Aug 26").
|
||||||
|
export function formatHumanDate(dateStr: string | undefined): string {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
const d = new Date(dateStr.slice(0, 10) + 'T00:00:00');
|
||||||
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
|
const weekday = d.toLocaleDateString('en-GB', { weekday: 'long' });
|
||||||
|
const mon = d.toLocaleDateString('en-GB', { month: 'short' });
|
||||||
|
const sameYear = d.getFullYear() === new Date().getFullYear();
|
||||||
|
return `${weekday} ${d.getDate()} ${mon}${sameYear ? '' : ' ' + String(d.getFullYear()).slice(2)}`;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import PocketBase from 'pocketbase';
|
import PocketBase from 'pocketbase';
|
||||||
import { PUBLIC_PB_URL } from '$app/env/public';
|
import { SERVER_IP } from '$app/env/public';
|
||||||
|
const PB_ENDPOINT = import.meta.env.PROD ? '/pb' : `http://${SERVER_IP}:8090`;
|
||||||
export const pb = new PocketBase(PUBLIC_PB_URL);
|
export const pb = new PocketBase(PB_ENDPOINT);
|
||||||
pb.autoCancellation(false);
|
pb.autoCancellation(false);
|
||||||
|
|
||||||
export function initPbFromCookie() {
|
export function initPbFromCookie() {
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import type { RequestEvent } from '@sveltejs/kit';
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
import { SERVER_IP, PROXY_PORT } from '../../../../config.ts';
|
import { PROXY_URL } from '$app/env/public';
|
||||||
|
|
||||||
const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`;
|
|
||||||
|
|
||||||
export function getSession(event: RequestEvent) {
|
export function getSession(event: RequestEvent) {
|
||||||
return event.locals.session;
|
return event.locals.session;
|
||||||
@@ -17,7 +15,7 @@ export function requireAuth(event: RequestEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function signup(email: string, password: string, famName: string, parentName?: string) {
|
export async function signup(email: string, password: string, famName: string, parentName?: string) {
|
||||||
const res = await fetch(`${HONO_URL}/api/admin/signup`, {
|
const res = await fetch(`${PROXY_URL}/api/admin/signup`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email, password, famName, parentName }),
|
body: JSON.stringify({ email, password, famName, parentName }),
|
||||||
@@ -29,7 +27,7 @@ export async function signup(email: string, password: string, famName: string, p
|
|||||||
|
|
||||||
export async function login(email: string, password: string) {
|
export async function login(email: string, password: string) {
|
||||||
console.log(email);
|
console.log(email);
|
||||||
const res = await fetch(`${HONO_URL}/api/admin/login`, {
|
const res = await fetch(`${PROXY_URL}/api/admin/login`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password }),
|
||||||
@@ -40,7 +38,7 @@ export async function login(email: string, password: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function joinMember(inviteCode: string, name: string, deviceToken: string) {
|
export async function joinMember(inviteCode: string, name: string, deviceToken: string) {
|
||||||
const res = await fetch(`${HONO_URL}/api/members/join`, {
|
const res = await fetch(`${PROXY_URL}/api/members/join`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ inviteCode, name, deviceToken }),
|
body: JSON.stringify({ inviteCode, name, deviceToken }),
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { SERVER_IP, PROXY_PORT } from '../../../../config.ts';
|
|
||||||
import type { RequestEvent } from '@sveltejs/kit';
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
|
import { PROXY_URL } from '$app/env/public';
|
||||||
const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`;
|
|
||||||
|
|
||||||
function sessionHeaders(event: RequestEvent): Record<string, string> {
|
function sessionHeaders(event: RequestEvent): Record<string, string> {
|
||||||
const s = event.locals.session;
|
const s = event.locals.session;
|
||||||
@@ -19,7 +17,7 @@ async function request(
|
|||||||
body?: unknown,
|
body?: unknown,
|
||||||
headers?: Record<string, string>
|
headers?: Record<string, string>
|
||||||
) {
|
) {
|
||||||
const res = await fetch(`${HONO_URL}${path}`, {
|
const res = await fetch(`${PROXY_URL}${path}`, {
|
||||||
method,
|
method,
|
||||||
headers: headers || { 'Content-Type': 'application/json' },
|
headers: headers || { 'Content-Type': 'application/json' },
|
||||||
body: body ? JSON.stringify(body) : undefined
|
body: body ? JSON.stringify(body) : undefined
|
||||||
@@ -81,7 +79,11 @@ export const hono = {
|
|||||||
return request(
|
return request(
|
||||||
'PATCH',
|
'PATCH',
|
||||||
`/api/admin/${famId}/fam`,
|
`/api/admin/${famId}/fam`,
|
||||||
{ payday, ...(paydayTime !== undefined ? { paydayTime } : {}), ...(timezone !== undefined ? { timezone } : {}) },
|
{
|
||||||
|
payday,
|
||||||
|
...(paydayTime !== undefined ? { paydayTime } : {}),
|
||||||
|
...(timezone !== undefined ? { timezone } : {})
|
||||||
|
},
|
||||||
sessionHeaders(event)
|
sessionHeaders(event)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -111,11 +113,11 @@ export const hono = {
|
|||||||
sessionHeaders(event)
|
sessionHeaders(event)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
async issueAllRewards(event: RequestEvent, famId: string, memberId: string, message?: string) {
|
async issueAllRewards(event: RequestEvent, famId: string, memberId: string) {
|
||||||
return request(
|
return request(
|
||||||
'POST',
|
'POST',
|
||||||
`/api/admin/${famId}/rewards/issue-all`,
|
`/api/admin/${famId}/rewards/issue-all`,
|
||||||
{ memberId, message },
|
{ memberId },
|
||||||
sessionHeaders(event)
|
sessionHeaders(event)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -196,14 +198,6 @@ export const hono = {
|
|||||||
sessionHeaders(event)
|
sessionHeaders(event)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
async sendMessage(event: RequestEvent, famId: string, memberId: string, message: string) {
|
|
||||||
return request(
|
|
||||||
'POST',
|
|
||||||
`/api/admin/${famId}/send-message`,
|
|
||||||
{ memberId, message },
|
|
||||||
sessionHeaders(event)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
async memberChores(event: RequestEvent, famId: string, memberId: string) {
|
async memberChores(event: RequestEvent, famId: string, memberId: string) {
|
||||||
return request(
|
return request(
|
||||||
'GET',
|
'GET',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { SERVER_IP, PB_PORT, PB_EMAIL, PB_PASSWORD } from '../../../../config.ts';
|
import { PB_EMAIL, PB_PASSWORD } from '$app/env/private';
|
||||||
|
import { SERVER_IP } from '$app/env/public';
|
||||||
const PB_ENDPOINT = `http://${SERVER_IP}:${PB_PORT}`;
|
export const PB_ENDPOINT = import.meta.env.PROD ? '/pb' : `http://${SERVER_IP}:8090`;
|
||||||
|
|
||||||
let token: string | null = null;
|
let token: string | null = null;
|
||||||
let tokenExpiry = 0;
|
let tokenExpiry = 0;
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import { pb } from '$lib/pocketbase';
|
||||||
|
import type { ChatMessage, TypingRow } from '$lib/types';
|
||||||
|
|
||||||
|
interface ChatInit {
|
||||||
|
famId: string;
|
||||||
|
actorId: string;
|
||||||
|
actorType: 'admin' | 'member';
|
||||||
|
actorName: string;
|
||||||
|
actorColor: string;
|
||||||
|
deviceToken?: string;
|
||||||
|
memberId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ChatStore {
|
||||||
|
famId = $state('');
|
||||||
|
messages = $state<ChatMessage[]>([]);
|
||||||
|
typing = $state<Record<string, TypingRow>>({});
|
||||||
|
unread = $state(0);
|
||||||
|
open = $state(false);
|
||||||
|
initialized = $state(false);
|
||||||
|
|
||||||
|
actorId = $state('');
|
||||||
|
actorType = $state<'admin' | 'member'>('member');
|
||||||
|
actorName = $state('');
|
||||||
|
actorColor = $state('');
|
||||||
|
deviceToken = $state('');
|
||||||
|
memberId = $state('');
|
||||||
|
|
||||||
|
private unsubs: (() => void)[] = [];
|
||||||
|
private destroyed = false;
|
||||||
|
private initPromise: Promise<void> | null = null;
|
||||||
|
private lastSeenAt = 0;
|
||||||
|
private typingTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
typingNames = $derived.by(() => {
|
||||||
|
const names: string[] = [];
|
||||||
|
for (const row of Object.values(this.typing)) {
|
||||||
|
if (row.actorId !== this.actorId) names.push(row.authorName);
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
});
|
||||||
|
|
||||||
|
async init(opts: ChatInit) {
|
||||||
|
this.famId = opts.famId;
|
||||||
|
this.actorId = opts.actorId;
|
||||||
|
this.actorType = opts.actorType;
|
||||||
|
this.actorName = opts.actorName;
|
||||||
|
this.actorColor = opts.actorColor;
|
||||||
|
this.deviceToken = opts.deviceToken || '';
|
||||||
|
this.memberId = opts.memberId || '';
|
||||||
|
|
||||||
|
if (this.initialized && this.famId === opts.famId) return;
|
||||||
|
if (this.initPromise) {
|
||||||
|
await this.initPromise;
|
||||||
|
if (this.initialized && this.famId === opts.famId) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cleanup();
|
||||||
|
this.destroyed = false;
|
||||||
|
this.lastSeenAt = Date.now();
|
||||||
|
|
||||||
|
this.initPromise = (async () => {
|
||||||
|
try {
|
||||||
|
// Last week + this week of history (custom createdAt field — the
|
||||||
|
// auto `created` field can't be filtered in this PB version).
|
||||||
|
const since = new Date(Date.now() - 14 * 86400000).toISOString();
|
||||||
|
const msgs = (await pb.collection('messages').getFullList({
|
||||||
|
filter: `famId = '${opts.famId}' && createdAt >= '${since}'`,
|
||||||
|
sort: 'createdAt'
|
||||||
|
})) as ChatMessage[];
|
||||||
|
this.messages = msgs;
|
||||||
|
this.initialized = true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('ChatStore.init failed:', e);
|
||||||
|
this.initPromise = null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
await this.subscribe();
|
||||||
|
this.initPromise = null;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return this.initPromise!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async subscribe() {
|
||||||
|
const msgSub = pb
|
||||||
|
.collection('messages')
|
||||||
|
.subscribe('*', (data: any) => {
|
||||||
|
if (this.destroyed) return;
|
||||||
|
this.onMessage(data.action, data.record);
|
||||||
|
})
|
||||||
|
.then((unsub) => this.unsubs.push(unsub))
|
||||||
|
.catch((err: Error) => console.error('[chatStore] messages subscribe failed:', err));
|
||||||
|
|
||||||
|
const typingSub = pb
|
||||||
|
.collection('chat_typing')
|
||||||
|
.subscribe('*', (data: any) => {
|
||||||
|
if (this.destroyed) return;
|
||||||
|
this.onTyping(data.action, data.record);
|
||||||
|
})
|
||||||
|
.then((unsub) => this.unsubs.push(unsub))
|
||||||
|
.catch((err: Error) => console.error('[chatStore] typing subscribe failed:', err));
|
||||||
|
|
||||||
|
await Promise.allSettled([msgSub, typingSub]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onMessage(action: string, record: ChatMessage) {
|
||||||
|
if (record.famId !== this.famId) return;
|
||||||
|
if (action === 'delete') {
|
||||||
|
this.messages = this.messages.filter((m) => m.id !== record.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'update') {
|
||||||
|
this.messages = this.messages.map((m) => (m.id === record.id ? { ...m, ...record } : m));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// create
|
||||||
|
if (!this.messages.some((m) => m.id === record.id)) {
|
||||||
|
this.messages = [...this.messages, record].sort((a, b) =>
|
||||||
|
a.createdAt.localeCompare(b.createdAt)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Stop showing their typing indicator once the message lands.
|
||||||
|
this.clearTyping(record.authorId);
|
||||||
|
if (record.authorId !== this.actorId && !this.open) {
|
||||||
|
this.unread++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onTyping(action: string, record: TypingRow) {
|
||||||
|
if (record.famId !== this.famId) return;
|
||||||
|
if (action === 'delete') {
|
||||||
|
this.removeTyping(record.actorId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!record.typing) {
|
||||||
|
this.removeTyping(record.actorId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.typing = { ...this.typing, [record.actorId]: record };
|
||||||
|
const prev = this.typingTimers.get(record.actorId);
|
||||||
|
if (prev) clearTimeout(prev);
|
||||||
|
this.typingTimers.set(
|
||||||
|
record.actorId,
|
||||||
|
setTimeout(() => this.removeTyping(record.actorId), 5000)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearTyping(actorId: string) {
|
||||||
|
if (this.typing[actorId]) this.removeTyping(actorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeTyping(actorId: string) {
|
||||||
|
if (!this.typing[actorId]) return;
|
||||||
|
const next = { ...this.typing };
|
||||||
|
delete next[actorId];
|
||||||
|
this.typing = next;
|
||||||
|
const t = this.typingTimers.get(actorId);
|
||||||
|
if (t) clearTimeout(t);
|
||||||
|
this.typingTimers.delete(actorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Writes via proxy ──
|
||||||
|
|
||||||
|
private async chatFetch(method: string, path: string, body?: unknown) {
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
|
if (this.actorType === 'member' && this.deviceToken) {
|
||||||
|
headers['x-device-token'] = this.deviceToken;
|
||||||
|
headers['x-device-famid'] = this.famId;
|
||||||
|
}
|
||||||
|
const res = await fetch(path, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body ? JSON.stringify(body) : undefined
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data.error || `${method} ${path} failed`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(content: string) {
|
||||||
|
const text = content.trim();
|
||||||
|
if (!text || !this.famId) return;
|
||||||
|
const temp: ChatMessage = {
|
||||||
|
id: 'temp-' + Date.now(),
|
||||||
|
famId: this.famId,
|
||||||
|
authorType: this.actorType,
|
||||||
|
authorId: this.actorId,
|
||||||
|
authorName: this.actorName,
|
||||||
|
authorColor: this.actorColor,
|
||||||
|
content: text,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
this.messages = [...this.messages, temp];
|
||||||
|
this.lastSeenAt = Date.now();
|
||||||
|
// Signal we stopped typing (message sent).
|
||||||
|
this.setTyping(false).catch(() => {});
|
||||||
|
try {
|
||||||
|
const saved = await this.chatFetch('POST', `/api/chat/${this.famId}/messages`, {
|
||||||
|
content: text
|
||||||
|
});
|
||||||
|
// SSE may have already delivered the real record via onMessage, so
|
||||||
|
// drop both the temp and any pre-existing copy of the saved id to
|
||||||
|
// avoid duplicate keys in the keyed each block.
|
||||||
|
this.messages = this.messages
|
||||||
|
.filter((m) => m.id !== temp.id && m.id !== saved.id)
|
||||||
|
.concat([{ ...saved, authorName: temp.authorName, authorColor: temp.authorColor }])
|
||||||
|
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
||||||
|
} catch (e) {
|
||||||
|
this.messages = this.messages.filter((m) => m.id !== temp.id);
|
||||||
|
console.error('Chat send failed:', e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private typingThrottle = 0;
|
||||||
|
async setTyping(typing: boolean) {
|
||||||
|
if (!this.famId) return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (typing && now - this.typingThrottle < 1500) return;
|
||||||
|
this.typingThrottle = now;
|
||||||
|
try {
|
||||||
|
await this.chatFetch('POST', `/api/chat/${this.famId}/typing`, { typing });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Chat typing failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
this.open = !this.open;
|
||||||
|
if (this.open) {
|
||||||
|
this.unread = 0;
|
||||||
|
this.lastSeenAt = Date.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
openChat() {
|
||||||
|
this.open = true;
|
||||||
|
this.unread = 0;
|
||||||
|
this.lastSeenAt = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
closeChat() {
|
||||||
|
this.open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
this.destroyed = true;
|
||||||
|
for (const unsub of this.unsubs) unsub();
|
||||||
|
this.unsubs = [];
|
||||||
|
for (const t of this.typingTimers.values()) clearTimeout(t);
|
||||||
|
this.typingTimers.clear();
|
||||||
|
this.initialized = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const chatStore = new ChatStore();
|
||||||
@@ -67,7 +67,7 @@ class FamStore {
|
|||||||
|
|
||||||
private initPromise: Promise<void> | null = null;
|
private initPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
async init(famId: string) {
|
async init(famId: string, initialFam?: Fam) {
|
||||||
if (this.initialized && this.famId === famId) return;
|
if (this.initialized && this.famId === famId) return;
|
||||||
|
|
||||||
// Wait for any in-flight init to finish first
|
// Wait for any in-flight init to finish first
|
||||||
@@ -82,8 +82,11 @@ class FamStore {
|
|||||||
|
|
||||||
this.initPromise = (async () => {
|
this.initPromise = (async () => {
|
||||||
try {
|
try {
|
||||||
|
// fams is superadmin-only (non-realtime, one-way writes). It is always
|
||||||
|
// fetched server-side by the layout load and passed in — never via the
|
||||||
|
// unauthenticated client PB SDK.
|
||||||
|
this.fam = initialFam || this.fam || ({} as Fam);
|
||||||
const [
|
const [
|
||||||
famRes,
|
|
||||||
membersRes,
|
membersRes,
|
||||||
templatesRes,
|
templatesRes,
|
||||||
assignedRes,
|
assignedRes,
|
||||||
@@ -93,7 +96,6 @@ class FamStore {
|
|||||||
rewardsRes,
|
rewardsRes,
|
||||||
seasonsRes
|
seasonsRes
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
pb.collection('fams').getOne(famId) as Promise<Fam>,
|
|
||||||
pb.collection('members').getFullList({ filter: `famId = '${famId}'` }) as Promise<
|
pb.collection('members').getFullList({ filter: `famId = '${famId}'` }) as Promise<
|
||||||
Member[]
|
Member[]
|
||||||
>,
|
>,
|
||||||
@@ -119,7 +121,6 @@ class FamStore {
|
|||||||
Season[]
|
Season[]
|
||||||
>
|
>
|
||||||
]);
|
]);
|
||||||
this.fam = famRes;
|
|
||||||
this.members = membersRes;
|
this.members = membersRes;
|
||||||
this.templates = templatesRes;
|
this.templates = templatesRes;
|
||||||
this.assigned = assignedRes;
|
this.assigned = assignedRes;
|
||||||
|
|||||||
@@ -172,6 +172,27 @@ export interface Settings {
|
|||||||
simulateEow?: boolean;
|
simulateEow?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
id: string;
|
||||||
|
famId: string;
|
||||||
|
authorType: 'admin' | 'member';
|
||||||
|
authorId: string;
|
||||||
|
authorName: string;
|
||||||
|
authorColor?: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TypingRow {
|
||||||
|
id: string;
|
||||||
|
famId: string;
|
||||||
|
actorId: string;
|
||||||
|
actorType: 'admin' | 'member';
|
||||||
|
authorName: string;
|
||||||
|
authorColor?: string;
|
||||||
|
typing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
famId: string;
|
famId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { SERVER_IP, PROXY_PORT } from '../../../../config.ts';
|
import { PROXY_URL } from '$app/env/public';
|
||||||
|
import { pbAdmin } from '$lib/server/pb-admin';
|
||||||
|
|
||||||
const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`;
|
const HONO_URL = PROXY_URL;
|
||||||
|
|
||||||
async function paydayCheck(famId: string, headers: Record<string, string>) {
|
async function paydayCheck(famId: string, headers: Record<string, string>) {
|
||||||
try {
|
try {
|
||||||
@@ -16,12 +17,40 @@ async function paydayCheck(famId: string, headers: Record<string, string>) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveChatIdentity(
|
||||||
|
api: 'admin' | 'member',
|
||||||
|
opts: {
|
||||||
|
session?: { famId: string; userId: string };
|
||||||
|
deviceToken?: string;
|
||||||
|
famId?: string;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
|
if (api === 'admin' && opts.session) {
|
||||||
|
headers['x-session-famid'] = opts.session.famId;
|
||||||
|
headers['x-session-userid'] = opts.session.userId;
|
||||||
|
} else if (api === 'member' && opts.deviceToken && opts.famId) {
|
||||||
|
headers['x-device-token'] = opts.deviceToken;
|
||||||
|
headers['x-device-famid'] = opts.famId;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const res = await fetch(`${HONO_URL}/api/chat/me`, { headers });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return await res.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function load(event) {
|
export async function load(event) {
|
||||||
const session = event.locals.session || null;
|
const session = event.locals.session || null;
|
||||||
const isParent = session !== null;
|
const isParent = session !== null;
|
||||||
const deviceToken = event.cookies.get('device_token') || '';
|
const deviceToken = event.cookies.get('device_token') || '';
|
||||||
|
|
||||||
let famId = '';
|
let famId = '';
|
||||||
|
let chat: { famId: string; actor: any } | null = null;
|
||||||
|
|
||||||
if (isParent) {
|
if (isParent) {
|
||||||
famId = session.famId;
|
famId = session.famId;
|
||||||
@@ -29,6 +58,7 @@ export async function load(event) {
|
|||||||
'x-session-famid': session.famId,
|
'x-session-famid': session.famId,
|
||||||
'x-session-userid': session.userId
|
'x-session-userid': session.userId
|
||||||
});
|
});
|
||||||
|
chat = await resolveChatIdentity('admin', { session });
|
||||||
} else if (deviceToken) {
|
} else if (deviceToken) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${HONO_URL}/api/members/seasons`, {
|
const res = await fetch(`${HONO_URL}/api/members/seasons`, {
|
||||||
@@ -44,8 +74,20 @@ export async function load(event) {
|
|||||||
'x-device-token': deviceToken,
|
'x-device-token': deviceToken,
|
||||||
'x-device-famid': famId
|
'x-device-famid': famId
|
||||||
});
|
});
|
||||||
|
chat = await resolveChatIdentity('member', { deviceToken, famId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { session, isParent, role: session?.role || 'child', famId };
|
return {
|
||||||
|
session,
|
||||||
|
isParent,
|
||||||
|
role: session?.role || 'child',
|
||||||
|
famId,
|
||||||
|
chat,
|
||||||
|
deviceToken,
|
||||||
|
// fams is superadmin-only (non-realtime). Fetched server-side for both roles.
|
||||||
|
fam: famId
|
||||||
|
? await pbAdmin.getOne('fams', famId).catch(() => null)
|
||||||
|
: null
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { initPbFromCookie } from '$lib/pocketbase';
|
import { initPbFromCookie } from '$lib/pocketbase';
|
||||||
import { famStore } from '$lib/stores/fam.svelte';
|
import { famStore } from '$lib/stores/fam.svelte';
|
||||||
import { Sidebar, TopNav, Footer } from '$lib/components';
|
import { chatStore } from '$lib/stores/chat.svelte';
|
||||||
|
import { Sidebar, TopNav, Footer, Chat } from '$lib/components';
|
||||||
|
import { chatIcon } from '$lib/components/icons';
|
||||||
import type { Session } from '$lib/types';
|
import type { Session } from '$lib/types';
|
||||||
|
|
||||||
let { children, data } = $props();
|
let { children, data } = $props();
|
||||||
@@ -24,7 +26,9 @@
|
|||||||
const rewards = famStore.rewards;
|
const rewards = famStore.rewards;
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
mounted = true;
|
mounted = true;
|
||||||
prevClaimedIds = new Set(rewards.filter((r: any) => r.status === 'requested').map((r: any) => r.id));
|
prevClaimedIds = new Set(
|
||||||
|
rewards.filter((r: any) => r.status === 'requested').map((r: any) => r.id)
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const r of rewards) {
|
for (const r of rewards) {
|
||||||
@@ -35,18 +39,39 @@
|
|||||||
setTimeout(() => (claimToast = ''), 5000);
|
setTimeout(() => (claimToast = ''), 5000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
prevClaimedIds = new Set(rewards.filter((r: any) => r.status === 'requested').map((r: any) => r.id));
|
prevClaimedIds = new Set(
|
||||||
|
rewards.filter((r: any) => r.status === 'requested').map((r: any) => r.id)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
initPbFromCookie();
|
initPbFromCookie();
|
||||||
if (page.data.famId) famStore.init(page.data.famId);
|
if (page.data.famId) famStore.init(page.data.famId, page.data.fam);
|
||||||
|
const chat = page.data.chat;
|
||||||
|
if (chat?.famId && chat?.actor) {
|
||||||
|
chatStore.init({
|
||||||
|
famId: chat.famId,
|
||||||
|
actorId: chat.actor.id,
|
||||||
|
actorType: chat.actor.type,
|
||||||
|
actorName: chat.actor.name,
|
||||||
|
actorColor: chat.actor.color || '#6366f1',
|
||||||
|
deviceToken: page.data.deviceToken || ''
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<div class="layout-stage" class:chat-open={chatStore.open}>
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
<Sidebar {famName} session={data.session} {isParent} {role} />
|
<Sidebar {famName} session={data.session} {isParent} {role} />
|
||||||
<TopNav {role} seasons={famStore.seasons} />
|
<TopNav {role} seasons={famStore.seasons}>
|
||||||
|
<button class="chat-toggle" onclick={() => chatStore.toggle()} aria-label="Open chat">
|
||||||
|
{@html chatIcon}
|
||||||
|
{#if chatStore.unread > 0}
|
||||||
|
<span class="chat-badge">{chatStore.unread > 9 ? '9+' : chatStore.unread}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</TopNav>
|
||||||
<main class="app-main">
|
<main class="app-main">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</main>
|
</main>
|
||||||
@@ -55,6 +80,12 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
|
{#if chatStore.open}
|
||||||
|
<button class="chat-backdrop" onclick={() => chatStore.closeChat()} aria-label="Close chat"
|
||||||
|
></button>
|
||||||
|
{/if}
|
||||||
|
<Chat {role} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.claim-toast {
|
.claim-toast {
|
||||||
@@ -75,6 +106,28 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: whitesmoke;
|
background: whitesmoke;
|
||||||
|
width: 100%;
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
}
|
||||||
|
/* Desktop: slide the whole app-shell left to reveal the 500px chat on the right. */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.layout-stage.chat-open .app-shell {
|
||||||
|
transform: translateX(-500px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* Mobile: chat covers the screen; app stays put under a dimmed backdrop. */
|
||||||
|
.chat-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
border: none;
|
||||||
|
z-index: 105;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.chat-backdrop {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.app-main {
|
.app-main {
|
||||||
margin-left: 220px;
|
margin-left: 220px;
|
||||||
@@ -83,4 +136,37 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
transition: margin-left 0.2s;
|
transition: margin-left 0.2s;
|
||||||
}
|
}
|
||||||
|
.chat-toggle {
|
||||||
|
position: relative;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.chat-toggle:hover {
|
||||||
|
background: #e5e7eb;
|
||||||
|
color: #4338ca;
|
||||||
|
}
|
||||||
|
.chat-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -4px;
|
||||||
|
right: -4px;
|
||||||
|
background: #10b981;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
import { hono } from '$lib/server/hono';
|
import { hono } from '$lib/server/hono';
|
||||||
import { memberApi } from '$lib/client/api';
|
import { PROXY_URL } from '$app/env/public';
|
||||||
import { SERVER_IP, PROXY_PORT } from '../../../../../config.ts';
|
|
||||||
|
|
||||||
const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`;
|
const HONO_URL = PROXY_URL;
|
||||||
|
|
||||||
export async function load(event) {
|
export async function load(event) {
|
||||||
const session = event.locals.session;
|
const session = event.locals.session;
|
||||||
@@ -101,7 +100,11 @@ export async function load(event) {
|
|||||||
|
|
||||||
let chores: any = {};
|
let chores: any = {};
|
||||||
try {
|
try {
|
||||||
chores = await memberApi.myChores(deviceToken, data.famId);
|
const choresRes = await fetch(`${HONO_URL}/api/members/my-chores`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'x-device-token': deviceToken, 'x-device-famid': data.famId }
|
||||||
|
});
|
||||||
|
chores = await choresRes.json();
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -174,12 +177,8 @@ export const actions = {
|
|||||||
const famId = event.locals.session.famId;
|
const famId = event.locals.session.famId;
|
||||||
const fd = await event.request.formData();
|
const fd = await event.request.formData();
|
||||||
const rewardId = fd.get('id') as string;
|
const rewardId = fd.get('id') as string;
|
||||||
const message = fd.get('message') as string;
|
|
||||||
try {
|
try {
|
||||||
const record = await hono.admin.claimReward(event, famId, rewardId);
|
const record = await hono.admin.claimReward(event, famId, rewardId);
|
||||||
if (message) {
|
|
||||||
await hono.admin.sendMessage(event, famId, record.memberId, message);
|
|
||||||
}
|
|
||||||
return { record };
|
return { record };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { error: e instanceof Error ? e.message : 'Failed to claim reward' };
|
return { error: e instanceof Error ? e.message : 'Failed to claim reward' };
|
||||||
@@ -191,9 +190,8 @@ export const actions = {
|
|||||||
const famId = event.locals.session.famId;
|
const famId = event.locals.session.famId;
|
||||||
const fd = await event.request.formData();
|
const fd = await event.request.formData();
|
||||||
const memberId = fd.get('memberId') as string;
|
const memberId = fd.get('memberId') as string;
|
||||||
const message = fd.get('message') as string;
|
|
||||||
try {
|
try {
|
||||||
const result = await hono.admin.issueAllRewards(event, famId, memberId, message || undefined);
|
const result = await hono.admin.issueAllRewards(event, famId, memberId);
|
||||||
return { count: result.count };
|
return { count: result.count };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { error: e instanceof Error ? e.message : 'Failed to issue rewards' };
|
return { error: e instanceof Error ? e.message : 'Failed to issue rewards' };
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { famStore } from '$lib/stores/fam.svelte';
|
import { famStore } from '$lib/stores/fam.svelte';
|
||||||
import { memberApi } from '$lib/client/api';
|
import { memberApi } from '$lib/client/api';
|
||||||
import { formatDDMMYY, formatShortDate } from '$lib/format';
|
import { formatDDMMYY, formatHumanDate } from '$lib/format';
|
||||||
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
||||||
import type { AssignedChore, Completion, ChoreTemplate, BonusConfig, Reward } from '$lib/types';
|
import type { AssignedChore, Completion, ChoreTemplate, BonusConfig, Reward } from '$lib/types';
|
||||||
import {
|
import {
|
||||||
@@ -15,18 +15,10 @@
|
|||||||
wallClockToUtc,
|
wallClockToUtc,
|
||||||
resolveTz,
|
resolveTz,
|
||||||
periodStart,
|
periodStart,
|
||||||
periodEnd
|
periodEnd,
|
||||||
|
isCompleteForPeriod
|
||||||
} from '../../../../../timezone.ts';
|
} from '../../../../../timezone.ts';
|
||||||
|
|
||||||
interface Notification {
|
|
||||||
id: string;
|
|
||||||
famId: string;
|
|
||||||
memberId: string;
|
|
||||||
message: string;
|
|
||||||
read: boolean;
|
|
||||||
created: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
let role = $state(data.role || 'child');
|
let role = $state(data.role || 'child');
|
||||||
@@ -43,15 +35,6 @@
|
|||||||
|
|
||||||
let simulateEow = $state(!!(data.settings?.simulateEow ?? data.simulateEow));
|
let simulateEow = $state(!!(data.settings?.simulateEow ?? data.simulateEow));
|
||||||
let eowPreview = $state<any>(null);
|
let eowPreview = $state<any>(null);
|
||||||
const responseTags = [
|
|
||||||
'👏 well done',
|
|
||||||
'😊 really pleased',
|
|
||||||
'🎯 you deserved that',
|
|
||||||
'💪 great effort',
|
|
||||||
'🙏 thanks'
|
|
||||||
];
|
|
||||||
let selectedMessage = $state<Record<string, string>>({});
|
|
||||||
let customMessage = $state<Record<string, string>>({});
|
|
||||||
let toast = $state('');
|
let toast = $state('');
|
||||||
|
|
||||||
let parentMembers = $derived(famStore.initialized ? famStore.members : data.members || []);
|
let parentMembers = $derived(famStore.initialized ? famStore.members : data.members || []);
|
||||||
@@ -254,7 +237,8 @@
|
|||||||
Math.max(
|
Math.max(
|
||||||
0,
|
0,
|
||||||
Math.round(
|
Math.round(
|
||||||
(new Date(addDays(weekStart, 7) + 'T00:00:00').getTime() - new Date(todayIso + 'T00:00:00').getTime()) /
|
(new Date(addDays(weekStart, 7) + 'T00:00:00').getTime() -
|
||||||
|
new Date(todayIso + 'T00:00:00').getTime()) /
|
||||||
86400000
|
86400000
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -513,42 +497,37 @@
|
|||||||
loading = false;
|
loading = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadAndDismiss();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadAndDismiss() {
|
|
||||||
if (!deviceToken || !famId) return;
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/members/notifications', {
|
|
||||||
headers: {
|
|
||||||
'x-device-token': deviceToken,
|
|
||||||
'x-device-famid': famId
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (!res.ok) return;
|
|
||||||
const all: Notification[] = await res.json();
|
|
||||||
for (const n of all.filter((n) => !n.read)) {
|
|
||||||
await fetch(`/api/members/notifications/${n.id}/dismiss`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'x-device-token': deviceToken,
|
|
||||||
'x-device-famid': famId
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isCompleted(assignedChoreId: string, date: string): boolean {
|
function isCompleted(assignedChoreId: string, date: string): boolean {
|
||||||
|
const a = assigned.find((x) => x.id === assignedChoreId);
|
||||||
|
// Weekly chores are "done for the week" — complete if completed any day
|
||||||
|
// in the current week, not just today.
|
||||||
|
if (a?.frequency === 'weekly') {
|
||||||
|
const dates = completions
|
||||||
|
.filter((c) => c.assignedChoreId === assignedChoreId)
|
||||||
|
.map((c) => c.date);
|
||||||
|
return isCompleteForPeriod('weekly', paydayDay, famTz, dates);
|
||||||
|
}
|
||||||
return completions.some(
|
return completions.some(
|
||||||
(c) => c.assignedChoreId === assignedChoreId && c.date?.slice(0, 10) === date
|
(c) => c.assignedChoreId === assignedChoreId && c.date?.slice(0, 10) === date
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function findCompletion(assignedChoreId: string) {
|
// Todos are one-off: done once the completion row exists, regardless of date.
|
||||||
|
function isTodoDone(todoId: string): boolean {
|
||||||
|
return completions.some((c) => c.assignedChoreId === todoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findCompletion(chore: AssignedChore) {
|
||||||
const match = (c: Completion) =>
|
const match = (c: Completion) =>
|
||||||
c.assignedChoreId === assignedChoreId && (c.date?.slice(0, 10) || c.date) === todayChild;
|
chore.isTodo
|
||||||
|
? c.assignedChoreId === chore.id
|
||||||
|
: chore.frequency === 'weekly'
|
||||||
|
? c.assignedChoreId === chore.id &&
|
||||||
|
(c.date?.slice(0, 10) || c.date) >= weekStart &&
|
||||||
|
(c.date?.slice(0, 10) || c.date) < addDays(weekStart, 7)
|
||||||
|
: c.assignedChoreId === chore.id && (c.date?.slice(0, 10) || c.date) === todayChild;
|
||||||
const optimistic = completions.find((c) => match(c) && c.id.startsWith('optimistic-'));
|
const optimistic = completions.find((c) => match(c) && c.id.startsWith('optimistic-'));
|
||||||
return optimistic || completions.find(match);
|
return optimistic || completions.find(match);
|
||||||
}
|
}
|
||||||
@@ -557,9 +536,9 @@
|
|||||||
if (togglingIds) return;
|
if (togglingIds) return;
|
||||||
togglingIds = chore.id;
|
togglingIds = chore.id;
|
||||||
|
|
||||||
const wasCompleted = isCompleted(chore.id, todayChild);
|
const wasCompleted = chore.isTodo ? isTodoDone(chore.id) : isCompleted(chore.id, todayChild);
|
||||||
if (wasCompleted) {
|
if (wasCompleted) {
|
||||||
const existing = findCompletion(chore.id);
|
const existing = findCompletion(chore);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
famStore.applyRecord('completions', existing, 'delete');
|
famStore.applyRecord('completions', existing, 'delete');
|
||||||
}
|
}
|
||||||
@@ -777,7 +756,10 @@
|
|||||||
(sum: number, r: any) => sum + Number(r.value),
|
(sum: number, r: any) => sum + Number(r.value),
|
||||||
0
|
0
|
||||||
)}
|
)}
|
||||||
{@const total = cashClaimable.reduce((sum: number, r: any) => sum + Number(r.value), 0)}
|
{@const total = cashClaimable.reduce(
|
||||||
|
(sum: number, r: any) => sum + Number(r.value),
|
||||||
|
0
|
||||||
|
)}
|
||||||
{@const hasClaims = requested.length > 0 || outstanding.length > 0}
|
{@const hasClaims = requested.length > 0 || outstanding.length > 0}
|
||||||
{#if hasClaims}
|
{#if hasClaims}
|
||||||
<div class="member-claims">
|
<div class="member-claims">
|
||||||
@@ -797,7 +779,6 @@
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input type="hidden" name="memberId" value={m.id} />
|
<input type="hidden" name="memberId" value={m.id} />
|
||||||
<input type="hidden" name="message" value={selectedMessage[m.id] || ''} />
|
|
||||||
<Button type="submit" size="sm" variant="secondary"
|
<Button type="submit" size="sm" variant="secondary"
|
||||||
>Issue All ({cashRequested.length}) · £{requestedTotal.toFixed(2)}</Button
|
>Issue All ({cashRequested.length}) · £{requestedTotal.toFixed(2)}</Button
|
||||||
>
|
>
|
||||||
@@ -815,7 +796,6 @@
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input type="hidden" name="id" value={r.id} />
|
<input type="hidden" name="id" value={r.id} />
|
||||||
<input type="hidden" name="message" value={selectedMessage[m.id] || ''} />
|
|
||||||
<div class="payment-row">
|
<div class="payment-row">
|
||||||
<span>{r.label}</span>
|
<span>{r.label}</span>
|
||||||
<div class="payment-right">
|
<div class="payment-right">
|
||||||
@@ -835,37 +815,12 @@
|
|||||||
<span class="value">
|
<span class="value">
|
||||||
{rewardLabel(r)}
|
{rewardLabel(r)}
|
||||||
{#if paydayLocked(r)}
|
{#if paydayLocked(r)}
|
||||||
<span class="owe-locked">🔒 {formatShortDate(r.settleDate)}</span>
|
<span class="owe-locked">🔒 {formatHumanDate(r.settleDate)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="response-area">
|
|
||||||
<p class="respond-label">Response:</p>
|
|
||||||
<div class="tags">
|
|
||||||
{#each responseTags as tag}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="tag"
|
|
||||||
class:selected={selectedMessage[m.id] === tag}
|
|
||||||
onclick={() =>
|
|
||||||
(selectedMessage[m.id] = selectedMessage[m.id] === tag ? '' : tag)}
|
|
||||||
>{tag}</button
|
|
||||||
>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="custom-msg"
|
|
||||||
placeholder="Or type your own..."
|
|
||||||
bind:value={customMessage[m.id]}
|
|
||||||
oninput={() => {
|
|
||||||
if (customMessage[m.id]) selectedMessage[m.id] = customMessage[m.id];
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
@@ -966,8 +921,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
{#if simulateEow}
|
{#if simulateEow}
|
||||||
<div class="preview-notice">
|
<div class="preview-notice">
|
||||||
👀 <b>Payday preview</b> — your parent is checking this week's payday. Nothing is paid
|
👀 <b>Payday preview</b> — your parent is checking this week's payday. Nothing is paid out yet.
|
||||||
out yet.
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if owedCash > 0}
|
{#if owedCash > 0}
|
||||||
@@ -1205,10 +1159,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="column col-weekly">
|
<div class="column col-weekly">
|
||||||
<h2>
|
<h2>
|
||||||
📅 Weekly ({weeklyPending.length +
|
📅 Weekly ({weeklyPending.length + memberTodos.filter((t) => !isTodoDone(t.id)).length})
|
||||||
memberTodos.filter((t) => !isCompleted(t.id, todayChild)).length})
|
|
||||||
</h2>
|
</h2>
|
||||||
{#if weeklyPending.length === 0 && memberTodos.filter((t) => !isCompleted(t.id, todayChild)).length === 0}
|
{#if weeklyPending.length === 0 && memberTodos.filter((t) => !isTodoDone(t.id)).length === 0}
|
||||||
<p class="empty">All done!</p>
|
<p class="empty">All done!</p>
|
||||||
{:else}
|
{:else}
|
||||||
{#each weeklyPending as chore}
|
{#each weeklyPending as chore}
|
||||||
@@ -1218,7 +1171,7 @@
|
|||||||
<span class="chore-value">{chore.value} {chore.type}</span>
|
<span class="chore-value">{chore.value} {chore.type}</span>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
{#each memberTodos.filter((t) => !isCompleted(t.id, todayChild)) as todo}
|
{#each memberTodos.filter((t) => !isTodoDone(t.id)) as todo}
|
||||||
{@const urgency = todoUrgency(todo)}
|
{@const urgency = todoUrgency(todo)}
|
||||||
{#if todo.type === 'emoji'}
|
{#if todo.type === 'emoji'}
|
||||||
<div
|
<div
|
||||||
@@ -1307,8 +1260,7 @@
|
|||||||
{:else if isReq}
|
{:else if isReq}
|
||||||
<span class="wr-pending">⏳ waiting</span>
|
<span class="wr-pending">⏳ waiting</span>
|
||||||
{:else if paydayLocked(r)}
|
{:else if paydayLocked(r)}
|
||||||
<span class="wr-pending wr-locked"
|
<span class="wr-pending wr-locked">🔒 pays out {formatHumanDate(r.settleDate)}</span
|
||||||
>🔒 pays out {formatShortDate(r.settleDate)}</span
|
|
||||||
>
|
>
|
||||||
{:else}
|
{:else}
|
||||||
<button
|
<button
|
||||||
@@ -1369,7 +1321,10 @@
|
|||||||
padding: 0.65rem 0.85rem;
|
padding: 0.65rem 0.85rem;
|
||||||
background: white;
|
background: white;
|
||||||
margin-bottom: 0.6rem;
|
margin-bottom: 0.6rem;
|
||||||
transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s;
|
transition:
|
||||||
|
transform 0.15s,
|
||||||
|
box-shadow 0.15s,
|
||||||
|
border-color 0.15s;
|
||||||
}
|
}
|
||||||
.member-card:hover {
|
.member-card:hover {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
@@ -1478,7 +1433,9 @@
|
|||||||
padding: 0.65rem 0.85rem;
|
padding: 0.65rem 0.85rem;
|
||||||
background: #f0fdf4;
|
background: #f0fdf4;
|
||||||
margin-bottom: 0.6rem;
|
margin-bottom: 0.6rem;
|
||||||
transition: transform 0.15s, box-shadow 0.15s;
|
transition:
|
||||||
|
transform 0.15s,
|
||||||
|
box-shadow 0.15s;
|
||||||
}
|
}
|
||||||
.trigger-card:hover {
|
.trigger-card:hover {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
@@ -1542,41 +1499,6 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
.respond-label {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: #6b7280;
|
|
||||||
margin: 0.4rem 0 0.3rem;
|
|
||||||
}
|
|
||||||
.tags {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.3rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.tag {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
padding: 0.2rem 0.5rem;
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: white;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.tag:hover {
|
|
||||||
background: #f3f4f6;
|
|
||||||
}
|
|
||||||
.tag.selected {
|
|
||||||
background: #6366f1;
|
|
||||||
color: white;
|
|
||||||
border-color: #6366f1;
|
|
||||||
}
|
|
||||||
.custom-msg {
|
|
||||||
margin-top: 0.3rem;
|
|
||||||
padding: 0.3rem 0.5rem;
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
color: #dc2626;
|
color: #dc2626;
|
||||||
|
|||||||
@@ -262,7 +262,6 @@
|
|||||||
<p class="empty">No templates yet</p>
|
<p class="empty">No templates yet</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<Button onclick={() => (showCreateModal = true)}>New Template</Button>
|
<Button onclick={() => (showCreateModal = true)}>New Template</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -3,12 +3,14 @@
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { famStore } from '$lib/stores/fam.svelte';
|
import { famStore } from '$lib/stores/fam.svelte';
|
||||||
import { ViewHeader, CardGrid, Card } from '$lib/components';
|
import { ViewHeader, CardGrid, Card } from '$lib/components';
|
||||||
import { formatShortDate } from '$lib/format';
|
import { formatHumanDate } from '$lib/format';
|
||||||
import type { ChoreTemplate, AssignedChore, Member, Season, Completion } from '$lib/types';
|
import type { ChoreTemplate, AssignedChore, Member, Season, Completion } from '$lib/types';
|
||||||
|
|
||||||
// ── Chevron SVG icons ──
|
// ── Chevron SVG icons ──
|
||||||
const CHEVRON_DOWN = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>';
|
const CHEVRON_DOWN =
|
||||||
const CHEVRON_UP = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg>';
|
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>';
|
||||||
|
const CHEVRON_UP =
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg>';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
@@ -354,7 +356,6 @@
|
|||||||
<p class="empty">No templates yet</p>
|
<p class="empty">No templates yet</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<button class="add-inline" onclick={() => (showCreateModal = true)}>+ New template</button>
|
<button class="add-inline" onclick={() => (showCreateModal = true)}>+ New template</button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -378,7 +379,9 @@
|
|||||||
<div class="accordion-wrapper accordion-todos">
|
<div class="accordion-wrapper accordion-todos">
|
||||||
<button class="accordion-head" onclick={() => toggleAccordion(m.id, 'todos')}>
|
<button class="accordion-head" onclick={() => toggleAccordion(m.id, 'todos')}>
|
||||||
<span class="accordion-label">📋 Todos ({todosForMember(m.id).length})</span>
|
<span class="accordion-label">📋 Todos ({todosForMember(m.id).length})</span>
|
||||||
<span class="accordion-chevron">{@html (accordionState[m.id]?.todos ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span>
|
<span class="accordion-chevron"
|
||||||
|
>{@html (accordionState[m.id]?.todos ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span
|
||||||
|
>
|
||||||
</button>
|
</button>
|
||||||
{#if accordionState[m.id]?.todos ?? true}
|
{#if accordionState[m.id]?.todos ?? true}
|
||||||
<div class="accordion-body">
|
<div class="accordion-body">
|
||||||
@@ -403,10 +406,14 @@
|
|||||||
{#if a.type === 'emoji'}
|
{#if a.type === 'emoji'}
|
||||||
<span class="todo-admin-type">🎯 emoji</span>
|
<span class="todo-admin-type">🎯 emoji</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="todo-admin-type"><span class="badge badge-pts">{a.value} pts</span></span>
|
<span class="todo-admin-type"
|
||||||
|
><span class="badge badge-pts">{a.value} pts</span></span
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
{#if a.completeBy}
|
{#if a.completeBy}
|
||||||
<span class="todo-admin-deadline">due {formatShortDate(a.completeBy)}</span>
|
<span class="todo-admin-deadline"
|
||||||
|
>due {formatHumanDate(a.completeBy)}</span
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -429,7 +436,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
<button class="add-inline accordion-add" onclick={() => openTodo(m.id)}>+ Add a todo</button>
|
<button class="add-inline accordion-add" onclick={() => openTodo(m.id)}
|
||||||
|
>+ Add a todo</button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -438,7 +447,9 @@
|
|||||||
<div class="accordion-wrapper accordion-chores">
|
<div class="accordion-wrapper accordion-chores">
|
||||||
<button class="accordion-head" onclick={() => toggleAccordion(m.id, 'chores')}>
|
<button class="accordion-head" onclick={() => toggleAccordion(m.id, 'chores')}>
|
||||||
<span class="accordion-label">Chores ({assignedForMember(m.id).length})</span>
|
<span class="accordion-label">Chores ({assignedForMember(m.id).length})</span>
|
||||||
<span class="accordion-chevron">{@html (accordionState[m.id]?.chores ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span>
|
<span class="accordion-chevron"
|
||||||
|
>{@html (accordionState[m.id]?.chores ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span
|
||||||
|
>
|
||||||
</button>
|
</button>
|
||||||
{#if accordionState[m.id]?.chores ?? true}
|
{#if accordionState[m.id]?.chores ?? true}
|
||||||
<div class="accordion-body">
|
<div class="accordion-body">
|
||||||
@@ -819,7 +830,9 @@
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s, border-color 0.15s;
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
border-color 0.15s;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.template-section > .add-inline {
|
.template-section > .add-inline {
|
||||||
@@ -852,7 +865,10 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s;
|
transition:
|
||||||
|
transform 0.15s,
|
||||||
|
box-shadow 0.15s,
|
||||||
|
border-color 0.15s;
|
||||||
}
|
}
|
||||||
.card:hover {
|
.card:hover {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
@@ -905,7 +921,9 @@
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
transition: color 0.15s, background 0.15s;
|
transition:
|
||||||
|
color 0.15s,
|
||||||
|
background 0.15s;
|
||||||
}
|
}
|
||||||
.edit-btn:hover {
|
.edit-btn:hover {
|
||||||
color: #6366f1;
|
color: #6366f1;
|
||||||
@@ -1093,7 +1111,10 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
transition: transform 0.15s, box-shadow 0.15s, opacity 0.2s;
|
transition:
|
||||||
|
transform 0.15s,
|
||||||
|
box-shadow 0.15s,
|
||||||
|
opacity 0.2s;
|
||||||
}
|
}
|
||||||
.todo-admin-card:hover {
|
.todo-admin-card:hover {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
|
|||||||
+6
-3
@@ -4,11 +4,14 @@ import { hono } from '$lib/server/hono';
|
|||||||
export async function load(event) {
|
export async function load(event) {
|
||||||
if (!event.locals.session) throw redirect(303, '/login');
|
if (!event.locals.session) throw redirect(303, '/login');
|
||||||
const famId = event.locals.session.famId;
|
const famId = event.locals.session.famId;
|
||||||
const [rewards, members] = await Promise.all([
|
const [rewards, members, assigned, templates, completions] = await Promise.all([
|
||||||
hono.admin.rewards(event, famId),
|
hono.admin.rewards(event, famId),
|
||||||
hono.admin.list(event, 'members', famId),
|
hono.admin.list(event, 'members', famId),
|
||||||
|
hono.admin.list(event, 'assigned-chores', famId),
|
||||||
|
hono.admin.list(event, 'chore-templates', famId),
|
||||||
|
hono.admin.completions(event, famId)
|
||||||
]);
|
]);
|
||||||
return { rewards, members };
|
return { rewards, members, assigned, templates, completions };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
@@ -23,5 +26,5 @@ export const actions = {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { error: e instanceof Error ? e.message : 'Failed to claim reward' };
|
return { error: e instanceof Error ? e.message : 'Failed to claim reward' };
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { famStore } from '$lib/stores/fam.svelte';
|
||||||
|
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
||||||
|
import { formatDDMMYY } from '$lib/format';
|
||||||
|
import type { Member, AssignedChore, ChoreTemplate, Completion, Reward } from '$lib/types';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
// Derived from famStore so SSE updates flow through (AGENTS.md rule).
|
||||||
|
let rewards = $derived(
|
||||||
|
famStore.initialized ? famStore.rewards : ((data.rewards || []) as Reward[])
|
||||||
|
);
|
||||||
|
let members = $derived(
|
||||||
|
famStore.initialized ? famStore.members : ((data.members || []) as Member[])
|
||||||
|
);
|
||||||
|
let assigned = $derived(
|
||||||
|
famStore.initialized ? famStore.assigned : ((data.assigned || []) as AssignedChore[])
|
||||||
|
);
|
||||||
|
let completions = $derived(
|
||||||
|
famStore.initialized ? famStore.completions : ((data.completions || []) as Completion[])
|
||||||
|
);
|
||||||
|
let templates = $derived(
|
||||||
|
famStore.initialized ? famStore.templates : ((data.templates || []) as ChoreTemplate[])
|
||||||
|
);
|
||||||
|
|
||||||
|
let activeTab = $state('rewards');
|
||||||
|
|
||||||
|
let memberMap = $derived.by(() => new Map(members.map((m: any) => [m.id, m])));
|
||||||
|
let templateMap = $derived.by(() => new Map(templates.map((t: any) => [t.id, t])));
|
||||||
|
let assignedMap = $derived.by(() => new Map(assigned.map((a: any) => [a.id, a])));
|
||||||
|
|
||||||
|
function memberName(memberId: string): string {
|
||||||
|
return memberMap.get(memberId)?.name || '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
function memberColor(memberId: string): string {
|
||||||
|
return memberMap.get(memberId)?.color || '#6366f1';
|
||||||
|
}
|
||||||
|
|
||||||
|
function choreName(a: any): string {
|
||||||
|
return a?.customName || templateMap.get(a?.templateId)?.name || 'Chore';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rewards tab ──
|
||||||
|
function rewardAmount(r: any): string {
|
||||||
|
if (r.rewardType === 'cash') return `£${Number(r.value).toFixed(2)}`;
|
||||||
|
if (r.rewardType === 'points') return `${r.value} pts`;
|
||||||
|
return r.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewardStatus(r: any): string {
|
||||||
|
if (r.status === 'unclaimed') return 'Outstanding';
|
||||||
|
if (r.status === 'requested') return 'Requested';
|
||||||
|
if (r.claimedAt?.slice(0, 10) === r.date) return 'Auto';
|
||||||
|
return 'Claimed';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOutstanding(r: any): boolean {
|
||||||
|
return r.status === 'unclaimed';
|
||||||
|
}
|
||||||
|
function isRequested(r: any): boolean {
|
||||||
|
return r.status === 'requested';
|
||||||
|
}
|
||||||
|
|
||||||
|
let sortedRewards = $derived(
|
||||||
|
[...rewards].sort((a: any, b: any) => {
|
||||||
|
const da = a.date || a.id || '';
|
||||||
|
const db = b.date || b.id || '';
|
||||||
|
return da < db ? 1 : da > db ? -1 : 0;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
let totalOutstanding = $derived(
|
||||||
|
rewards
|
||||||
|
.filter((r: any) => r.status !== 'claimed')
|
||||||
|
.filter((r: any) => r.rewardType === 'cash')
|
||||||
|
.reduce((sum: number, r: any) => sum + Number(r.value), 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Chores / Todos tabs ──
|
||||||
|
let choreCompletions = $derived(
|
||||||
|
completions.filter((c: any) => !assignedMap.get(c.assignedChoreId)?.isTodo)
|
||||||
|
);
|
||||||
|
let todoCompletions = $derived(
|
||||||
|
completions.filter((c: any) => assignedMap.get(c.assignedChoreId)?.isTodo)
|
||||||
|
);
|
||||||
|
|
||||||
|
let sortedChoreCompletions = $derived(
|
||||||
|
[...choreCompletions].sort((a: any, b: any) => (b.date || '').localeCompare(a.date || ''))
|
||||||
|
);
|
||||||
|
let sortedTodoCompletions = $derived(
|
||||||
|
[...todoCompletions].sort((a: any, b: any) => (b.date || '').localeCompare(a.date || ''))
|
||||||
|
);
|
||||||
|
|
||||||
|
let toast = $state('');
|
||||||
|
|
||||||
|
async function fire() {
|
||||||
|
const { default: confetti } = await import('@hiseb/confetti');
|
||||||
|
confetti({
|
||||||
|
count: 80,
|
||||||
|
size: 4,
|
||||||
|
velocity: 500,
|
||||||
|
fade: true,
|
||||||
|
position: { x: window.innerWidth / 2, y: 0 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(msg: string) {
|
||||||
|
toast = msg;
|
||||||
|
setTimeout(() => (toast = ''), 4000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ViewHeader
|
||||||
|
title="Ledger"
|
||||||
|
subtitle="Rewards, chores and todos earned by members"
|
||||||
|
hero
|
||||||
|
tabs={{
|
||||||
|
items: [
|
||||||
|
{ label: 'Rewards', value: 'rewards' },
|
||||||
|
{ label: 'Chores', value: 'chores' },
|
||||||
|
{ label: 'Todos', value: 'todos' }
|
||||||
|
],
|
||||||
|
active: activeTab,
|
||||||
|
onchange: (v: string) => (activeTab = v)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if toast}
|
||||||
|
<div class="toast">{toast}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<CardGrid>
|
||||||
|
{#if activeTab === 'rewards'}
|
||||||
|
<Card cols={3}>
|
||||||
|
{#if rewards.length === 0}
|
||||||
|
<p class="empty">
|
||||||
|
No rewards yet. Points earned will automatically create bonus rewards when thresholds are
|
||||||
|
met.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Member</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th class="num">Amount</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each sortedRewards as r}
|
||||||
|
{@const outstanding = isOutstanding(r)}
|
||||||
|
<tr class:claimed={!outstanding && !isRequested(r)} class:requested={isRequested(r)}>
|
||||||
|
<td class="date">{r.date ? formatDDMMYY(r.date) : '—'}</td>
|
||||||
|
<td>
|
||||||
|
<span class="dot" style="background:{memberColor(r.memberId)}"></span>
|
||||||
|
{memberName(r.memberId)}
|
||||||
|
</td>
|
||||||
|
<td>{r.label}</td>
|
||||||
|
<td class="num">{rewardAmount(r)}</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="status-badge"
|
||||||
|
class:outstanding
|
||||||
|
class:requested-status={isRequested(r)}
|
||||||
|
class:claimed-status={!outstanding && !isRequested(r)}
|
||||||
|
>
|
||||||
|
{rewardStatus(r)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{#if outstanding}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/claim"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async (args: any) => {
|
||||||
|
const d = args.result.data || {};
|
||||||
|
if (d.error) showToast(d.error);
|
||||||
|
else if (args.result.type === 'success') fire();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input name="id" type="hidden" value={r.id} />
|
||||||
|
<Button type="submit" size="sm">Claim</Button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
<td class="num"><strong>Outstanding cash</strong></td>
|
||||||
|
<td class="num"><strong>£{totalOutstanding.toFixed(2)}</strong></td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</Card>
|
||||||
|
{:else if activeTab === 'chores'}
|
||||||
|
<Card cols={3} scrollX title="Chore completions">
|
||||||
|
{#if sortedChoreCompletions.length === 0}
|
||||||
|
<p class="empty">No chore completions yet.</p>
|
||||||
|
{:else}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Member</th>
|
||||||
|
<th>Chore</th>
|
||||||
|
<th class="num">Value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each sortedChoreCompletions as c}
|
||||||
|
{@const a = assignedMap.get(c.assignedChoreId)}
|
||||||
|
<tr>
|
||||||
|
<td class="date">{formatDDMMYY(c.date)}</td>
|
||||||
|
<td>
|
||||||
|
<span class="dot" style="background:{memberColor(c.memberId)}"></span>
|
||||||
|
{memberName(c.memberId)}
|
||||||
|
</td>
|
||||||
|
<td>{choreName(a)}</td>
|
||||||
|
<td class="num">
|
||||||
|
{#if a?.type === 'money'}£{Number(a.value).toFixed(2)}
|
||||||
|
{:else}{a?.value ?? 0} pts{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</Card>
|
||||||
|
{:else}
|
||||||
|
<Card cols={3} scrollX title="Todo completions">
|
||||||
|
{#if sortedTodoCompletions.length === 0}
|
||||||
|
<p class="empty">No todos completed yet.</p>
|
||||||
|
{:else}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Completed</th>
|
||||||
|
<th>Member</th>
|
||||||
|
<th>Todo</th>
|
||||||
|
<th>Due</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each sortedTodoCompletions as c}
|
||||||
|
{@const a = assignedMap.get(c.assignedChoreId)}
|
||||||
|
<tr>
|
||||||
|
<td class="date">{formatDDMMYY(c.date)}</td>
|
||||||
|
<td>
|
||||||
|
<span class="dot" style="background:{memberColor(c.memberId)}"></span>
|
||||||
|
{memberName(c.memberId)}
|
||||||
|
</td>
|
||||||
|
<td>{choreName(a)}</td>
|
||||||
|
<td class="date">{a?.completeBy ? formatDDMMYY(a.completeBy) : '—'}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
|
</CardGrid>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
top: 3.5rem;
|
||||||
|
right: 1.5rem;
|
||||||
|
background: #dc2626;
|
||||||
|
color: white;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
z-index: 999;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
color: #9ca3af;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
background: #f9fafb;
|
||||||
|
font-weight: 600;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
.num {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.date {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #6b7280;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
tr.claimed {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
tr.claimed td {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
tr.requested {
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
.dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 0.3rem;
|
||||||
|
}
|
||||||
|
.status-badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.status-badge.outstanding {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
.status-badge.requested-status {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
.status-badge.claimed-status {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
tfoot td {
|
||||||
|
border-top: 2px solid #d1d5db;
|
||||||
|
padding-top: 0.6rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { hono } from '$lib/server/hono';
|
import { hono } from '$lib/server/hono';
|
||||||
import { SERVER_IP, PROXY_PORT } from '../../../../../../config.ts';
|
import { PROXY_URL } from '$app/env/public';
|
||||||
|
|
||||||
const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`;
|
const HONO_URL = PROXY_URL;
|
||||||
|
|
||||||
export async function load(event) {
|
export async function load(event) {
|
||||||
const session = event.locals.session;
|
const session = event.locals.session;
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { enhance } from '$app/forms';
|
|
||||||
import { famStore } from '$lib/stores/fam.svelte';
|
|
||||||
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
|
||||||
|
|
||||||
let { data } = $props();
|
|
||||||
|
|
||||||
let rewards = $state(famStore.initialized ? famStore.rewards : (data.rewards || []))
|
|
||||||
let members = $state(famStore.initialized ? famStore.members : (data.members || []))
|
|
||||||
|
|
||||||
function memberName(memberId: string): string {
|
|
||||||
return famStore.memberMap().get(memberId)?.name || members.find((m: any) => m.id === memberId)?.name || '?';
|
|
||||||
}
|
|
||||||
|
|
||||||
function memberColor(memberId: string): string {
|
|
||||||
return famStore.memberMap().get(memberId)?.color || members.find((m: any) => m.id === memberId)?.color || '#6366f1';
|
|
||||||
}
|
|
||||||
|
|
||||||
function rewardAmount(r: any): string {
|
|
||||||
if (r.rewardType === 'cash') return `£${Number(r.value).toFixed(2)}`;
|
|
||||||
if (r.rewardType === 'points') return `${r.value} pts`;
|
|
||||||
return r.label;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rewardStatus(r: any): string {
|
|
||||||
if (r.status === 'unclaimed') return 'Outstanding';
|
|
||||||
if (r.status === 'requested') return 'Requested';
|
|
||||||
if (r.claimedAt?.slice(0, 10) === r.date) return 'Auto';
|
|
||||||
return 'Claimed';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isOutstanding(r: any): boolean { return r.status === 'unclaimed'; }
|
|
||||||
function isRequested(r: any): boolean { return r.status === 'requested'; }
|
|
||||||
|
|
||||||
let sorted = $derived([...rewards].sort((a: any, b: any) => {
|
|
||||||
const da = a.date || a.id || '';
|
|
||||||
const db = b.date || b.id || '';
|
|
||||||
return da < db ? 1 : da > db ? -1 : 0;
|
|
||||||
}))
|
|
||||||
|
|
||||||
let toast = $state('')
|
|
||||||
|
|
||||||
async function fire() {
|
|
||||||
const { default: confetti } = await import('@hiseb/confetti');
|
|
||||||
confetti({ count: 80, size: 4, velocity: 500, fade: true, position: { x: window.innerWidth / 2, y: 0 } });
|
|
||||||
}
|
|
||||||
|
|
||||||
function showToast(msg: string) {
|
|
||||||
toast = msg;
|
|
||||||
setTimeout(() => toast = '', 4000);
|
|
||||||
}
|
|
||||||
|
|
||||||
let totalOutstanding = $derived(
|
|
||||||
rewards
|
|
||||||
.filter((r: any) => r.status !== 'claimed')
|
|
||||||
.filter((r: any) => r.rewardType === 'cash')
|
|
||||||
.reduce((sum: number, r: any) => sum + Number(r.value), 0)
|
|
||||||
)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<ViewHeader title="Reward Ledger" subtitle="All rewards earned by members" hero />
|
|
||||||
|
|
||||||
{#if toast}
|
|
||||||
<div class="toast">{toast}</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<CardGrid>
|
|
||||||
<Card cols={3}>
|
|
||||||
{#if rewards.length === 0}
|
|
||||||
<p style="color:#9ca3af;text-align:center;padding:2rem">No rewards yet. Points earned will automatically create bonus rewards when thresholds are met.</p>
|
|
||||||
{:else}
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Date</th>
|
|
||||||
<th>Member</th>
|
|
||||||
<th>Description</th>
|
|
||||||
<th class="num">Amount</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each sorted as r}
|
|
||||||
{@const outstanding = isOutstanding(r)}
|
|
||||||
<tr class:claimed={!outstanding && !isRequested(r)} class:requested={isRequested(r)}>
|
|
||||||
<td class="date">{r.date?.slice(0, 10) || '—'}</td>
|
|
||||||
<td>
|
|
||||||
<span class="dot" style="background:{memberColor(r.memberId)}"></span>
|
|
||||||
{memberName(r.memberId)}
|
|
||||||
</td>
|
|
||||||
<td>{r.label}</td>
|
|
||||||
<td class="num">{rewardAmount(r)}</td>
|
|
||||||
<td>
|
|
||||||
<span class="status-badge" class:outstanding class:requested-status={isRequested(r)} class:claimed-status={!outstanding && !isRequested(r)}>
|
|
||||||
{rewardStatus(r)}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{#if outstanding}
|
|
||||||
<form method="POST" action="?/claim" use:enhance={() => { return async (args: any) => { const d = args.result.data || {}; if (d.error) showToast(d.error); else if (args.result.type === 'success') { fire(); } }; }}>
|
|
||||||
<input name="id" type="hidden" value={r.id} />
|
|
||||||
<Button type="submit" size="sm">Claim</Button>
|
|
||||||
</form>
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr>
|
|
||||||
<td></td>
|
|
||||||
<td></td>
|
|
||||||
<td class="num"><strong>Outstanding cash</strong></td>
|
|
||||||
<td class="num"><strong>£{totalOutstanding.toFixed(2)}</strong></td>
|
|
||||||
<td></td>
|
|
||||||
<td></td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
{/if}
|
|
||||||
</Card>
|
|
||||||
</CardGrid>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.toast { position: fixed; top: 3.5rem; right: 1.5rem; background: #dc2626; color: white; padding: 0.6rem 1rem; border-radius: 8px; font-size: 0.85rem; z-index: 999; box-shadow: 0 4px 12px rgba(0,0,0,0.15); }
|
|
||||||
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
|
|
||||||
th, td { padding: 0.5rem 0.6rem; border-bottom: 1px solid #e5e7eb; text-align: left; }
|
|
||||||
th { background: #f9fafb; font-weight: 600; position: sticky; top: 0; }
|
|
||||||
.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
||||||
.date { font-family: monospace; font-size: 0.85rem; color: #6b7280; white-space: nowrap; }
|
|
||||||
tr.claimed { opacity: 0.5; }
|
|
||||||
tr.claimed td { color: #9ca3af; }
|
|
||||||
tr.requested { background: #eff6ff; }
|
|
||||||
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 0.3rem; }
|
|
||||||
.status-badge { font-size: 0.75rem; padding: 2px 8px; border-radius: 10px; }
|
|
||||||
.status-badge.outstanding { background: #fef3c7; color: #92400e; }
|
|
||||||
.status-badge.requested-status { background: #dbeafe; color: #1e40af; }
|
|
||||||
.status-badge.claimed-status { background: #d1fae5; color: #065f46; }
|
|
||||||
tfoot td { border-top: 2px solid #d1d5db; padding-top: 0.6rem; }
|
|
||||||
</style>
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { onMount, onDestroy } from 'svelte';
|
|
||||||
import { pb, initPbFromCookie } from '$lib/pocketbase';
|
|
||||||
import { famStore } from '$lib/stores/fam.svelte';
|
import { famStore } from '$lib/stores/fam.svelte';
|
||||||
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
||||||
import { COMMON_TIMEZONES } from '../../../../../../timezone.ts';
|
import { COMMON_TIMEZONES } from '../../../../../../timezone.ts';
|
||||||
@@ -11,8 +9,8 @@
|
|||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
// fam is sensitive (inviteCode, stripeCustomerId, featureFlags) — never in the
|
// fam is sensitive (inviteCode, stripeCustomerId, featureFlags) — never in the
|
||||||
// public famStore stream. This admin-only page subscribes to `fams` with the
|
// public famStore stream. It is superadmin-only, fetched server-side by the
|
||||||
// authenticated PB instance (pb_token cookie), page-local only.
|
// layout load. Writes go through form actions; no live fam subscription.
|
||||||
let fam = $state(data.fam);
|
let fam = $state(data.fam);
|
||||||
let famSlug = $state(page.params.fam);
|
let famSlug = $state(page.params.fam);
|
||||||
|
|
||||||
@@ -65,32 +63,6 @@
|
|||||||
let members = $state(famStore.initialized ? famStore.members : data.members || []);
|
let members = $state(famStore.initialized ? famStore.members : data.members || []);
|
||||||
let deletingSeason = $state<any>(null);
|
let deletingSeason = $state<any>(null);
|
||||||
|
|
||||||
function syncFamFromRecord(record: any) {
|
|
||||||
fam = record;
|
|
||||||
payday = Number(record.payday ?? 1);
|
|
||||||
paydayTime = record.paydayTime || '18:00';
|
|
||||||
timezone = record.timezone || 'auto';
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
initPbFromCookie();
|
|
||||||
if (!fam?.id) return;
|
|
||||||
try {
|
|
||||||
await pb.collection('fams').subscribe(fam.id, ({ action, record }) => {
|
|
||||||
if (action === 'update') syncFamFromRecord(record);
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.error('fams subscribe failed:', e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
if (fam?.id)
|
|
||||||
pb.collection('fams')
|
|
||||||
.unsubscribe(fam.id)
|
|
||||||
.catch(() => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
function copy(url: string) {
|
function copy(url: string) {
|
||||||
navigator.clipboard.writeText(url);
|
navigator.clipboard.writeText(url);
|
||||||
copied = true;
|
copied = true;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { pbAdmin } from '$lib/server/pb-admin';
|
import { pbAdmin } from '$lib/server/pb-admin';
|
||||||
import { redirect, fail } from '@sveltejs/kit';
|
import { redirect, fail } from '@sveltejs/kit';
|
||||||
import { PB_EMAIL, PB_PASSWORD } from '../../../../config.ts';
|
import { PB_EMAIL, PB_PASSWORD } from '$app/env/private';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ cookies }) => {
|
export const load: PageServerLoad = async ({ cookies }) => {
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
import { DEBUG_RECORD_ID } from '$app/env/private';
|
|
||||||
export async function load() {
|
|
||||||
return {
|
|
||||||
recordId: DEBUG_RECORD_ID
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { pb } from "$lib/pocketbase";
|
|
||||||
let { data } = $props();
|
|
||||||
let records = $state(null);
|
|
||||||
$effect(async () => {
|
|
||||||
records = await pb.collection('debug').getOne(data.recordId);
|
|
||||||
return await pb.collection('debug').subscribe(data.recordId, ({ action, record }) => {
|
|
||||||
records = record
|
|
||||||
// if (action === 'create') {
|
|
||||||
// todos = [...todos, record];
|
|
||||||
// }
|
|
||||||
|
|
||||||
// if (action === 'update') {
|
|
||||||
// todos = todos.map((t) =>
|
|
||||||
// t.id === record.id ? record : t
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
||||||
// if (action === 'delete') {
|
|
||||||
// todos = todos.filter((t) =>
|
|
||||||
// t.id !== record.id
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
async function incrementHono() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/increment-hono', { method: 'POST' });
|
|
||||||
const data = await response.json();
|
|
||||||
console.log(data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<h1>Debug Dashboard</h1>
|
|
||||||
|
|
||||||
<div class="counters">
|
|
||||||
<div class="card">
|
|
||||||
<h2>Hono</h2>
|
|
||||||
{#if records}
|
|
||||||
<p class="value">{records.hono_count}</p>
|
|
||||||
{/if}
|
|
||||||
<button onclick={incrementHono}>+1 Hono</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
h1 {
|
|
||||||
text-align: center;
|
|
||||||
margin: 2rem 0;
|
|
||||||
}
|
|
||||||
.counters {
|
|
||||||
display: flex;
|
|
||||||
gap: 2rem;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
min-width: 200px;
|
|
||||||
}
|
|
||||||
.value {
|
|
||||||
font-size: 3rem;
|
|
||||||
font-weight: bold;
|
|
||||||
margin: 1rem 0;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
padding: 0.5rem 1.5rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
import { setDeviceTokenCookie } from '$lib/server/auth';
|
import { setDeviceTokenCookie } from '$lib/server/auth';
|
||||||
import { SERVER_IP, PROXY_PORT } from '../../../../../../config.ts';
|
import { PROXY_URL } from '$app/env/public';
|
||||||
|
|
||||||
const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`;
|
const HONO_URL = PROXY_URL;
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
default: async (event) => {
|
default: async (event) => {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import tailwindcss from '@tailwindcss/vite';
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import adapter from '@sveltejs/adapter-node';
|
import adapter from '@sveltejs/adapter-node';
|
||||||
import { sveltekit } from '@sveltejs/kit/vite';
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
import { SERVER_IP, FRONTEND_PORT, PROXY_PORT } from '../config.ts';
|
|
||||||
|
|
||||||
export default defineConfig(() => {
|
export default defineConfig(() => {
|
||||||
return {
|
return {
|
||||||
@@ -25,11 +24,13 @@ export default defineConfig(() => {
|
|||||||
fs: {
|
fs: {
|
||||||
allow: ['.', './node_modules', '../node_modules']
|
allow: ['.', './node_modules', '../node_modules']
|
||||||
},
|
},
|
||||||
allowedHosts: [SERVER_IP],
|
// Dev only: allow access via any host/LAN IP without hardcoding it.
|
||||||
port: FRONTEND_PORT,
|
allowedHosts: true,
|
||||||
|
port: 2080,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: `http://${SERVER_IP}:${PROXY_PORT}`,
|
// The vite dev server and Hono proxy run on the same host.
|
||||||
|
target: `http://127.0.0.1:3456`,
|
||||||
changeOrigin: true
|
changeOrigin: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-2
@@ -1,9 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "famchamp-monorepo",
|
"name": "famchamp-monorepo",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"packageManager": "pnpm@10.30.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm -r --parallel dev",
|
"dev": "lsof -ti tcp:3456 | xargs -r kill -9 && pnpm -r --parallel dev",
|
||||||
|
"start": "pnpm dev",
|
||||||
"build": "pnpm -r build"
|
"build": "pnpm -r build"
|
||||||
},
|
},
|
||||||
"version": "0.2.0"
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"esbuild"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"version": "1.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+243
@@ -10,9 +10,18 @@ importers:
|
|||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@hiseb/confetti':
|
||||||
|
specifier: ^2.0.2
|
||||||
|
version: 2.2.0
|
||||||
|
chart.js:
|
||||||
|
specifier: ^4.4.0
|
||||||
|
version: 4.5.1
|
||||||
pocketbase:
|
pocketbase:
|
||||||
specifier: ^0.27.0
|
specifier: ^0.27.0
|
||||||
version: 0.27.0
|
version: 0.27.0
|
||||||
|
qrcode:
|
||||||
|
specifier: ^1.5.4
|
||||||
|
version: 1.5.4
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@sveltejs/adapter-node':
|
'@sveltejs/adapter-node':
|
||||||
specifier: next
|
specifier: next
|
||||||
@@ -66,6 +75,9 @@ importers:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^26.0.0
|
specifier: ^26.0.0
|
||||||
version: 26.0.0
|
version: 26.0.0
|
||||||
|
esbuild:
|
||||||
|
specifier: ^0.28.1
|
||||||
|
version: 0.28.1
|
||||||
tsx:
|
tsx:
|
||||||
specifier: ^4.19.0
|
specifier: ^4.19.0
|
||||||
version: 4.22.4
|
version: 4.22.4
|
||||||
@@ -249,6 +261,9 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@hiseb/confetti@2.2.0':
|
||||||
|
resolution: {integrity: sha512-iCcTe2AS2Mnj7f2BGPnOetjnX+Qs1jgnKU0GSYyQHFB42psio0EpgxmPXOXf2wcCGBH/W+1G2Ecl4hcbsin1Kg==}
|
||||||
|
|
||||||
'@hono/node-server@1.19.14':
|
'@hono/node-server@1.19.14':
|
||||||
resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==}
|
resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==}
|
||||||
engines: {node: '>=18.14.1'}
|
engines: {node: '>=18.14.1'}
|
||||||
@@ -271,6 +286,9 @@ packages:
|
|||||||
'@jridgewell/trace-mapping@0.3.31':
|
'@jridgewell/trace-mapping@0.3.31':
|
||||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||||
|
|
||||||
|
'@kurkle/color@0.3.4':
|
||||||
|
resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==}
|
||||||
|
|
||||||
'@napi-rs/wasm-runtime@1.1.5':
|
'@napi-rs/wasm-runtime@1.1.5':
|
||||||
resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==}
|
resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -630,6 +648,14 @@ packages:
|
|||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
ansi-regex@5.0.1:
|
||||||
|
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
ansi-styles@4.3.0:
|
||||||
|
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
aria-query@5.3.1:
|
aria-query@5.3.1:
|
||||||
resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==}
|
resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -638,18 +664,40 @@ packages:
|
|||||||
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
|
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
camelcase@5.3.1:
|
||||||
|
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
chart.js@4.5.1:
|
||||||
|
resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==}
|
||||||
|
engines: {pnpm: '>=8'}
|
||||||
|
|
||||||
chokidar@4.0.3:
|
chokidar@4.0.3:
|
||||||
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
|
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
|
||||||
engines: {node: '>= 14.16.0'}
|
engines: {node: '>= 14.16.0'}
|
||||||
|
|
||||||
|
cliui@6.0.0:
|
||||||
|
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
|
||||||
|
|
||||||
clsx@2.1.1:
|
clsx@2.1.1:
|
||||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
color-convert@2.0.1:
|
||||||
|
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||||
|
engines: {node: '>=7.0.0'}
|
||||||
|
|
||||||
|
color-name@1.1.4:
|
||||||
|
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
||||||
|
|
||||||
cookie@1.1.1:
|
cookie@1.1.1:
|
||||||
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
decamelize@1.2.0:
|
||||||
|
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
deepmerge@4.3.1:
|
deepmerge@4.3.1:
|
||||||
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
|
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -661,6 +709,12 @@ packages:
|
|||||||
devalue@5.8.1:
|
devalue@5.8.1:
|
||||||
resolution: {integrity: sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==}
|
resolution: {integrity: sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==}
|
||||||
|
|
||||||
|
dijkstrajs@1.0.3:
|
||||||
|
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
|
||||||
|
|
||||||
|
emoji-regex@8.0.0:
|
||||||
|
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||||
|
|
||||||
enhanced-resolve@5.21.6:
|
enhanced-resolve@5.21.6:
|
||||||
resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==}
|
resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
@@ -690,11 +744,19 @@ packages:
|
|||||||
picomatch:
|
picomatch:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
find-up@4.1.0:
|
||||||
|
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
|
get-caller-file@2.0.5:
|
||||||
|
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
||||||
|
engines: {node: 6.* || 8.* || >= 10.*}
|
||||||
|
|
||||||
graceful-fs@4.2.11:
|
graceful-fs@4.2.11:
|
||||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||||
|
|
||||||
@@ -702,6 +764,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==}
|
resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==}
|
||||||
engines: {node: '>=16.9.0'}
|
engines: {node: '>=16.9.0'}
|
||||||
|
|
||||||
|
is-fullwidth-code-point@3.0.0:
|
||||||
|
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
is-reference@3.0.3:
|
is-reference@3.0.3:
|
||||||
resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==}
|
resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==}
|
||||||
|
|
||||||
@@ -786,6 +852,10 @@ packages:
|
|||||||
locate-character@3.0.0:
|
locate-character@3.0.0:
|
||||||
resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
|
resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
|
||||||
|
|
||||||
|
locate-path@5.0.0:
|
||||||
|
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||||
|
|
||||||
@@ -806,6 +876,22 @@ packages:
|
|||||||
resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==}
|
resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==}
|
||||||
engines: {node: '>=12.20.0'}
|
engines: {node: '>=12.20.0'}
|
||||||
|
|
||||||
|
p-limit@2.3.0:
|
||||||
|
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
p-locate@4.1.0:
|
||||||
|
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
p-try@2.2.0:
|
||||||
|
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
path-exists@4.0.0:
|
||||||
|
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
picocolors@1.1.1:
|
picocolors@1.1.1:
|
||||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||||
|
|
||||||
@@ -813,6 +899,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
|
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
pngjs@5.0.0:
|
||||||
|
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
|
||||||
|
engines: {node: '>=10.13.0'}
|
||||||
|
|
||||||
pocketbase@0.27.0:
|
pocketbase@0.27.0:
|
||||||
resolution: {integrity: sha512-K5N6d93UP/BNMbMnlZ6BUfy9VPCIvLyqhJFOsNI8OsZwzvKWEAfyD36boi5K4ECIOl5HMlo0TzuaeGdKpMwizQ==}
|
resolution: {integrity: sha512-K5N6d93UP/BNMbMnlZ6BUfy9VPCIvLyqhJFOsNI8OsZwzvKWEAfyD36boi5K4ECIOl5HMlo0TzuaeGdKpMwizQ==}
|
||||||
|
|
||||||
@@ -887,10 +977,22 @@ packages:
|
|||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
qrcode@1.5.4:
|
||||||
|
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
|
||||||
|
engines: {node: '>=10.13.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
readdirp@4.1.2:
|
readdirp@4.1.2:
|
||||||
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
||||||
engines: {node: '>= 14.18.0'}
|
engines: {node: '>= 14.18.0'}
|
||||||
|
|
||||||
|
require-directory@2.1.1:
|
||||||
|
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
require-main-filename@2.0.0:
|
||||||
|
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
|
||||||
|
|
||||||
rolldown@1.0.3:
|
rolldown@1.0.3:
|
||||||
resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==}
|
resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -905,6 +1007,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
|
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
set-blocking@2.0.0:
|
||||||
|
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
|
||||||
|
|
||||||
sirv@3.0.2:
|
sirv@3.0.2:
|
||||||
resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
|
resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -913,6 +1018,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
string-width@4.2.3:
|
||||||
|
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
strip-ansi@6.0.1:
|
||||||
|
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
svelte-check@4.6.0:
|
svelte-check@4.6.0:
|
||||||
resolution: {integrity: sha512-KhVnDFDSid57mmZtHz8gfW8AAGylOZ0vPnOIzVmAL+urzwK8sBYXRss953gD8T0OdgAQ11mdWhE6uadmtOz8TQ==}
|
resolution: {integrity: sha512-KhVnDFDSid57mmZtHz8gfW8AAGylOZ0vPnOIzVmAL+urzwK8sBYXRss953gD8T0OdgAQ11mdWhE6uadmtOz8TQ==}
|
||||||
engines: {node: '>= 18.0.0'}
|
engines: {node: '>= 18.0.0'}
|
||||||
@@ -1012,6 +1125,24 @@ packages:
|
|||||||
vite:
|
vite:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
which-module@2.0.1:
|
||||||
|
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
|
||||||
|
|
||||||
|
wrap-ansi@6.2.0:
|
||||||
|
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
y18n@4.0.3:
|
||||||
|
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
|
||||||
|
|
||||||
|
yargs-parser@18.1.3:
|
||||||
|
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
yargs@15.4.1:
|
||||||
|
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
zimmerframe@1.1.4:
|
zimmerframe@1.1.4:
|
||||||
resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==}
|
resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==}
|
||||||
|
|
||||||
@@ -1127,6 +1258,8 @@ snapshots:
|
|||||||
'@esbuild/win32-x64@0.28.1':
|
'@esbuild/win32-x64@0.28.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@hiseb/confetti@2.2.0': {}
|
||||||
|
|
||||||
'@hono/node-server@1.19.14(hono@4.12.27)':
|
'@hono/node-server@1.19.14(hono@4.12.27)':
|
||||||
dependencies:
|
dependencies:
|
||||||
hono: 4.12.27
|
hono: 4.12.27
|
||||||
@@ -1150,6 +1283,8 @@ snapshots:
|
|||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
'@kurkle/color@0.3.4': {}
|
||||||
|
|
||||||
'@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
|
'@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/core': 1.10.0
|
'@emnapi/core': 1.10.0
|
||||||
@@ -1392,24 +1527,54 @@ snapshots:
|
|||||||
|
|
||||||
acorn@8.17.0: {}
|
acorn@8.17.0: {}
|
||||||
|
|
||||||
|
ansi-regex@5.0.1: {}
|
||||||
|
|
||||||
|
ansi-styles@4.3.0:
|
||||||
|
dependencies:
|
||||||
|
color-convert: 2.0.1
|
||||||
|
|
||||||
aria-query@5.3.1: {}
|
aria-query@5.3.1: {}
|
||||||
|
|
||||||
axobject-query@4.1.0: {}
|
axobject-query@4.1.0: {}
|
||||||
|
|
||||||
|
camelcase@5.3.1: {}
|
||||||
|
|
||||||
|
chart.js@4.5.1:
|
||||||
|
dependencies:
|
||||||
|
'@kurkle/color': 0.3.4
|
||||||
|
|
||||||
chokidar@4.0.3:
|
chokidar@4.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
readdirp: 4.1.2
|
readdirp: 4.1.2
|
||||||
|
|
||||||
|
cliui@6.0.0:
|
||||||
|
dependencies:
|
||||||
|
string-width: 4.2.3
|
||||||
|
strip-ansi: 6.0.1
|
||||||
|
wrap-ansi: 6.2.0
|
||||||
|
|
||||||
clsx@2.1.1: {}
|
clsx@2.1.1: {}
|
||||||
|
|
||||||
|
color-convert@2.0.1:
|
||||||
|
dependencies:
|
||||||
|
color-name: 1.1.4
|
||||||
|
|
||||||
|
color-name@1.1.4: {}
|
||||||
|
|
||||||
cookie@1.1.1: {}
|
cookie@1.1.1: {}
|
||||||
|
|
||||||
|
decamelize@1.2.0: {}
|
||||||
|
|
||||||
deepmerge@4.3.1: {}
|
deepmerge@4.3.1: {}
|
||||||
|
|
||||||
detect-libc@2.1.2: {}
|
detect-libc@2.1.2: {}
|
||||||
|
|
||||||
devalue@5.8.1: {}
|
devalue@5.8.1: {}
|
||||||
|
|
||||||
|
dijkstrajs@1.0.3: {}
|
||||||
|
|
||||||
|
emoji-regex@8.0.0: {}
|
||||||
|
|
||||||
enhanced-resolve@5.21.6:
|
enhanced-resolve@5.21.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
@@ -1454,13 +1619,22 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
picomatch: 4.0.4
|
picomatch: 4.0.4
|
||||||
|
|
||||||
|
find-up@4.1.0:
|
||||||
|
dependencies:
|
||||||
|
locate-path: 5.0.0
|
||||||
|
path-exists: 4.0.0
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
get-caller-file@2.0.5: {}
|
||||||
|
|
||||||
graceful-fs@4.2.11: {}
|
graceful-fs@4.2.11: {}
|
||||||
|
|
||||||
hono@4.12.27: {}
|
hono@4.12.27: {}
|
||||||
|
|
||||||
|
is-fullwidth-code-point@3.0.0: {}
|
||||||
|
|
||||||
is-reference@3.0.3:
|
is-reference@3.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.9
|
'@types/estree': 1.0.9
|
||||||
@@ -1518,6 +1692,10 @@ snapshots:
|
|||||||
|
|
||||||
locate-character@3.0.0: {}
|
locate-character@3.0.0: {}
|
||||||
|
|
||||||
|
locate-path@5.0.0:
|
||||||
|
dependencies:
|
||||||
|
p-locate: 4.1.0
|
||||||
|
|
||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
@@ -1530,10 +1708,24 @@ snapshots:
|
|||||||
|
|
||||||
obug@2.1.3: {}
|
obug@2.1.3: {}
|
||||||
|
|
||||||
|
p-limit@2.3.0:
|
||||||
|
dependencies:
|
||||||
|
p-try: 2.2.0
|
||||||
|
|
||||||
|
p-locate@4.1.0:
|
||||||
|
dependencies:
|
||||||
|
p-limit: 2.3.0
|
||||||
|
|
||||||
|
p-try@2.2.0: {}
|
||||||
|
|
||||||
|
path-exists@4.0.0: {}
|
||||||
|
|
||||||
picocolors@1.1.1: {}
|
picocolors@1.1.1: {}
|
||||||
|
|
||||||
picomatch@4.0.4: {}
|
picomatch@4.0.4: {}
|
||||||
|
|
||||||
|
pngjs@5.0.0: {}
|
||||||
|
|
||||||
pocketbase@0.27.0: {}
|
pocketbase@0.27.0: {}
|
||||||
|
|
||||||
postcss@8.5.15:
|
postcss@8.5.15:
|
||||||
@@ -1555,8 +1747,18 @@ snapshots:
|
|||||||
|
|
||||||
prettier@3.8.4: {}
|
prettier@3.8.4: {}
|
||||||
|
|
||||||
|
qrcode@1.5.4:
|
||||||
|
dependencies:
|
||||||
|
dijkstrajs: 1.0.3
|
||||||
|
pngjs: 5.0.0
|
||||||
|
yargs: 15.4.1
|
||||||
|
|
||||||
readdirp@4.1.2: {}
|
readdirp@4.1.2: {}
|
||||||
|
|
||||||
|
require-directory@2.1.1: {}
|
||||||
|
|
||||||
|
require-main-filename@2.0.0: {}
|
||||||
|
|
||||||
rolldown@1.0.3:
|
rolldown@1.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@oxc-project/types': 0.133.0
|
'@oxc-project/types': 0.133.0
|
||||||
@@ -1603,6 +1805,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
mri: 1.2.0
|
mri: 1.2.0
|
||||||
|
|
||||||
|
set-blocking@2.0.0: {}
|
||||||
|
|
||||||
sirv@3.0.2:
|
sirv@3.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@polka/url': 1.0.0-next.29
|
'@polka/url': 1.0.0-next.29
|
||||||
@@ -1611,6 +1815,16 @@ snapshots:
|
|||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
|
string-width@4.2.3:
|
||||||
|
dependencies:
|
||||||
|
emoji-regex: 8.0.0
|
||||||
|
is-fullwidth-code-point: 3.0.0
|
||||||
|
strip-ansi: 6.0.1
|
||||||
|
|
||||||
|
strip-ansi@6.0.1:
|
||||||
|
dependencies:
|
||||||
|
ansi-regex: 5.0.1
|
||||||
|
|
||||||
svelte-check@4.6.0(picomatch@4.0.4)(svelte@5.56.3)(typescript@6.0.3):
|
svelte-check@4.6.0(picomatch@4.0.4)(svelte@5.56.3)(typescript@6.0.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/trace-mapping': 0.3.31
|
'@jridgewell/trace-mapping': 0.3.31
|
||||||
@@ -1689,4 +1903,33 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
vite: 8.0.16(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)
|
vite: 8.0.16(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)
|
||||||
|
|
||||||
|
which-module@2.0.1: {}
|
||||||
|
|
||||||
|
wrap-ansi@6.2.0:
|
||||||
|
dependencies:
|
||||||
|
ansi-styles: 4.3.0
|
||||||
|
string-width: 4.2.3
|
||||||
|
strip-ansi: 6.0.1
|
||||||
|
|
||||||
|
y18n@4.0.3: {}
|
||||||
|
|
||||||
|
yargs-parser@18.1.3:
|
||||||
|
dependencies:
|
||||||
|
camelcase: 5.3.1
|
||||||
|
decamelize: 1.2.0
|
||||||
|
|
||||||
|
yargs@15.4.1:
|
||||||
|
dependencies:
|
||||||
|
cliui: 6.0.0
|
||||||
|
decamelize: 1.2.0
|
||||||
|
find-up: 4.1.0
|
||||||
|
get-caller-file: 2.0.5
|
||||||
|
require-directory: 2.1.1
|
||||||
|
require-main-filename: 2.0.0
|
||||||
|
set-blocking: 2.0.0
|
||||||
|
string-width: 4.2.3
|
||||||
|
which-module: 2.0.1
|
||||||
|
y18n: 4.0.3
|
||||||
|
yargs-parser: 18.1.3
|
||||||
|
|
||||||
zimmerframe@1.1.4: {}
|
zimmerframe@1.1.4: {}
|
||||||
|
|||||||
+4
-3
@@ -3,10 +3,10 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch --env-file-if-exists=../.env src/index.ts",
|
||||||
"build": "tsc",
|
"build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"seed": "tsx scripts/seed.ts"
|
"seed": "tsx --env-file-if-exists=../.env scripts/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.13.0",
|
"@hono/node-server": "^1.13.0",
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^26.0.0",
|
"@types/node": "^26.0.0",
|
||||||
|
"esbuild": "^0.28.1",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.7.0"
|
"typescript": "^5.7.0"
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-5
@@ -1,6 +1,4 @@
|
|||||||
import { SERVER_IP, PB_PORT, PB_EMAIL, PB_PASSWORD } from "../../config.ts";
|
import { PB_ENDPOINT, PB_EMAIL, PB_PASSWORD } from "../src/env.ts";
|
||||||
|
|
||||||
const PB_ENDPOINT = `http://${SERVER_IP}:${PB_PORT}`;
|
|
||||||
|
|
||||||
interface FieldDef {
|
interface FieldDef {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -121,8 +119,8 @@ async function main() {
|
|||||||
ids.fams = await createCollection(token, {
|
ids.fams = await createCollection(token, {
|
||||||
name: "fams",
|
name: "fams",
|
||||||
type: "base",
|
type: "base",
|
||||||
listRule: "",
|
listRule: null,
|
||||||
viewRule: "",
|
viewRule: null,
|
||||||
createRule: null,
|
createRule: null,
|
||||||
updateRule: null,
|
updateRule: null,
|
||||||
deleteRule: null,
|
deleteRule: null,
|
||||||
@@ -345,6 +343,44 @@ async function main() {
|
|||||||
rel("memberId", ids.members!, true),
|
rel("memberId", ids.members!, true),
|
||||||
rel("assignedChoreId", ids.assigned_chores!, true),
|
rel("assignedChoreId", ids.assigned_chores!, true),
|
||||||
date("date"),
|
date("date"),
|
||||||
|
date("completedAt"),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Chat collections (global family messages + transient typing presence)
|
||||||
|
ids.messages = await createCollection(token, {
|
||||||
|
name: "messages",
|
||||||
|
type: "base",
|
||||||
|
listRule: "",
|
||||||
|
viewRule: "",
|
||||||
|
createRule: null,
|
||||||
|
updateRule: null,
|
||||||
|
deleteRule: null,
|
||||||
|
fields: [
|
||||||
|
rel("famId", ids.fams!, true),
|
||||||
|
select("authorType", ["admin", "member"], true),
|
||||||
|
text("authorId", true),
|
||||||
|
text("authorName", true),
|
||||||
|
text("authorColor"),
|
||||||
|
text("content", true),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
ids.chat_typing = await createCollection(token, {
|
||||||
|
name: "chat_typing",
|
||||||
|
type: "base",
|
||||||
|
listRule: "",
|
||||||
|
viewRule: "",
|
||||||
|
createRule: null,
|
||||||
|
updateRule: null,
|
||||||
|
deleteRule: null,
|
||||||
|
fields: [
|
||||||
|
rel("famId", ids.fams!, true),
|
||||||
|
text("actorId", true),
|
||||||
|
select("actorType", ["admin", "member"], true),
|
||||||
|
text("authorName", true),
|
||||||
|
text("authorColor"),
|
||||||
|
bool("typing"),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Runtime env for the proxy. Same dev/prod split as the frontend: dev connects
|
||||||
|
// to the dev machine's PocketBase at SERVER_IP, prod connects to the
|
||||||
|
// container-internal loopback PB. Ports come from config.ts (single source).
|
||||||
|
//
|
||||||
|
// Env loading: dev uses `tsx --env-file=../.env` (see package.json) like vite
|
||||||
|
// does for the frontend; prod (docker) injects env via compose and has no .env,
|
||||||
|
// so SERVER_IP is unset → loopback below.
|
||||||
|
const SERVER_IP = process.env.SERVER_IP;
|
||||||
|
|
||||||
|
export const PB_ENDPOINT = SERVER_IP
|
||||||
|
? `http://${SERVER_IP}:8090`
|
||||||
|
: `http://127.0.0.1:8090`;
|
||||||
|
export const PB_EMAIL = process.env.PB_EMAIL || "debug@famchamp.dev";
|
||||||
|
export const PB_PASSWORD = process.env.PB_PASSWORD || "debug123";
|
||||||
+222
-130
@@ -3,6 +3,7 @@ import { serve } from "@hono/node-server";
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { pb } from "./pb.ts";
|
import { pb } from "./pb.ts";
|
||||||
import { migrate } from "./migrate.ts";
|
import { migrate } from "./migrate.ts";
|
||||||
|
import { PROXY_PORT } from "../../config.ts";
|
||||||
import {
|
import {
|
||||||
weekStart as tzWeekStart,
|
weekStart as tzWeekStart,
|
||||||
addDaysStr,
|
addDaysStr,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
wallClockToUtc,
|
wallClockToUtc,
|
||||||
resolveTz,
|
resolveTz,
|
||||||
nextPaydayAfter as nextPaydayAfterTz,
|
nextPaydayAfter as nextPaydayAfterTz,
|
||||||
|
periodWindow,
|
||||||
} from "../../timezone.ts";
|
} from "../../timezone.ts";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
@@ -613,7 +615,11 @@ app.patch("/api/admin/:famId/fam", requireAdmin, async (c) => {
|
|||||||
payday: record.payday,
|
payday: record.payday,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (body.payday !== undefined || body.paydayTime !== undefined || body.timezone !== undefined) {
|
if (
|
||||||
|
body.payday !== undefined ||
|
||||||
|
body.paydayTime !== undefined ||
|
||||||
|
body.timezone !== undefined
|
||||||
|
) {
|
||||||
const patch: Record<string, string | number> = {};
|
const patch: Record<string, string | number> = {};
|
||||||
if (body.payday !== undefined) {
|
if (body.payday !== undefined) {
|
||||||
const payday = Number(body.payday);
|
const payday = Number(body.payday);
|
||||||
@@ -629,8 +635,14 @@ app.patch("/api/admin/:famId/fam", requireAdmin, async (c) => {
|
|||||||
}
|
}
|
||||||
if (body.timezone !== undefined) {
|
if (body.timezone !== undefined) {
|
||||||
const timezone = String(body.timezone);
|
const timezone = String(body.timezone);
|
||||||
if (timezone !== "auto" && !/^[A-Za-z_+-]+\/[A-Za-z_+-]+$/.test(timezone))
|
if (
|
||||||
return c.json({ error: "timezone must be an IANA name or 'auto'" }, 400);
|
timezone !== "auto" &&
|
||||||
|
!/^[A-Za-z_+-]+\/[A-Za-z_+-]+$/.test(timezone)
|
||||||
|
)
|
||||||
|
return c.json(
|
||||||
|
{ error: "timezone must be an IANA name or 'auto'" },
|
||||||
|
400,
|
||||||
|
);
|
||||||
patch.timezone = timezone;
|
patch.timezone = timezone;
|
||||||
}
|
}
|
||||||
const record = await pb.update("fams", famId, patch);
|
const record = await pb.update("fams", famId, patch);
|
||||||
@@ -782,9 +794,7 @@ app.get("/api/admin/:famId/weekly-summary", requireAdmin, async (c) => {
|
|||||||
async function getFamSettings(famId: string): Promise<any> {
|
async function getFamSettings(famId: string): Promise<any> {
|
||||||
try {
|
try {
|
||||||
return (
|
return (
|
||||||
(
|
(await pb.getList("settings", `famId = '${famId}'`)).items?.[0] || {}
|
||||||
await pb.getList("settings", `famId = '${famId}'`)
|
|
||||||
).items?.[0] || {}
|
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return {};
|
return {};
|
||||||
@@ -818,7 +828,10 @@ app.patch("/api/admin/:famId/settings", requireAdmin, async (c) => {
|
|||||||
} else if (Object.keys(patch).length) {
|
} else if (Object.keys(patch).length) {
|
||||||
s = await pb.update("settings", s.id, patch);
|
s = await pb.update("settings", s.id, patch);
|
||||||
}
|
}
|
||||||
return c.json({ simulateEow: !!s.simulateEow, webhookUrl: s.webhookUrl || "" });
|
return c.json({
|
||||||
|
simulateEow: !!s.simulateEow,
|
||||||
|
webhookUrl: s.webhookUrl || "",
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return handleError(c, err);
|
return handleError(c, err);
|
||||||
}
|
}
|
||||||
@@ -842,7 +855,9 @@ app.get("/api/admin/:famId/debug/eow-preview", requireAdmin, async (c) => {
|
|||||||
pb
|
pb
|
||||||
.getList("bonus_configs", `famId = '${famId}' && status = 'active'`)
|
.getList("bonus_configs", `famId = '${famId}' && status = 'active'`)
|
||||||
.catch(() => ({ items: [] })),
|
.catch(() => ({ items: [] })),
|
||||||
pb.getList("rewards", `famId = '${famId}'`).catch(() => ({ items: [] })),
|
pb
|
||||||
|
.getList("rewards", `famId = '${famId}'`)
|
||||||
|
.catch(() => ({ items: [] })),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let rewardPointsList: any[] = [];
|
let rewardPointsList: any[] = [];
|
||||||
@@ -914,7 +929,9 @@ app.get("/api/admin/:famId/debug/eow-preview", requireAdmin, async (c) => {
|
|||||||
let current = 0;
|
let current = 0;
|
||||||
if (cfg.type === "threshold")
|
if (cfg.type === "threshold")
|
||||||
current = sourceComps.reduce((sum: number, c: any) => {
|
current = sourceComps.reduce((sum: number, c: any) => {
|
||||||
const ch = assignedList.find((a: any) => a.id === c.assignedChoreId);
|
const ch = assignedList.find(
|
||||||
|
(a: any) => a.id === c.assignedChoreId,
|
||||||
|
);
|
||||||
return sum + (ch?.type === "points" ? Number(ch.value) : 0);
|
return sum + (ch?.type === "points" ? Number(ch.value) : 0);
|
||||||
}, 0);
|
}, 0);
|
||||||
else if (cfg.type === "count") current = sourceComps.length;
|
else if (cfg.type === "count") current = sourceComps.length;
|
||||||
@@ -927,7 +944,10 @@ app.get("/api/admin/:famId/debug/eow-preview", requireAdmin, async (c) => {
|
|||||||
: members.items;
|
: members.items;
|
||||||
for (const m of targets) {
|
for (const m of targets) {
|
||||||
if (cfgRewards.some((r: any) => r.memberId === m.id)) continue;
|
if (cfgRewards.some((r: any) => r.memberId === m.id)) continue;
|
||||||
const current = tryEval(m, periodCompletions.filter((c: any) => c.memberId === m.id));
|
const current = tryEval(
|
||||||
|
m,
|
||||||
|
periodCompletions.filter((c: any) => c.memberId === m.id),
|
||||||
|
);
|
||||||
if (cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue))
|
if (cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue))
|
||||||
predictedRewards.push({
|
predictedRewards.push({
|
||||||
config: cfg.name,
|
config: cfg.name,
|
||||||
@@ -969,9 +989,7 @@ app.get("/api/admin/:famId/debug/eow-preview", requireAdmin, async (c) => {
|
|||||||
const eligible = qualified.length
|
const eligible = qualified.length
|
||||||
? qualified
|
? qualified
|
||||||
: scored.filter((st) => st.current > 0);
|
: scored.filter((st) => st.current > 0);
|
||||||
const winner = eligible.sort(
|
const winner = eligible.sort((aa, bb) => bb.current - aa.current)[0];
|
||||||
(aa, bb) => bb.current - aa.current,
|
|
||||||
)[0];
|
|
||||||
if (winner)
|
if (winner)
|
||||||
predictedRewards.push({
|
predictedRewards.push({
|
||||||
config: cfg.name,
|
config: cfg.name,
|
||||||
@@ -1299,7 +1317,9 @@ async function evaluateFam(famId: string): Promise<void> {
|
|||||||
const tzEval = await getFamTimezone(famId);
|
const tzEval = await getFamTimezone(famId);
|
||||||
|
|
||||||
for (const cfg of configs) {
|
for (const cfg of configs) {
|
||||||
const pStart2 = cfg.period ? periodStart(cfg.period, paydayEval, tzEval) : "";
|
const pStart2 = cfg.period
|
||||||
|
? periodStart(cfg.period, paydayEval, tzEval)
|
||||||
|
: "";
|
||||||
const pEnd = cfg.period ? periodEnd(cfg.period, pStart2) : "";
|
const pEnd = cfg.period ? periodEnd(cfg.period, pStart2) : "";
|
||||||
const periodCompletions = cfg.period
|
const periodCompletions = cfg.period
|
||||||
? allCompletions.items.filter(
|
? allCompletions.items.filter(
|
||||||
@@ -1397,7 +1417,9 @@ async function evaluateFam(famId: string): Promise<void> {
|
|||||||
if (!achieved && existingRewards.length > 0) {
|
if (!achieved && existingRewards.length > 0) {
|
||||||
for (const r of existingRewards) {
|
for (const r of existingRewards) {
|
||||||
if (r.status !== "claimed") {
|
if (r.status !== "claimed") {
|
||||||
try { await pb.delete("rewards", r.id); } catch {}
|
try {
|
||||||
|
await pb.delete("rewards", r.id);
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
@@ -1454,11 +1476,11 @@ async function evaluateFam(famId: string): Promise<void> {
|
|||||||
if (existingRewards.length > 0) {
|
if (existingRewards.length > 0) {
|
||||||
const existing = existingRewards[0];
|
const existing = existingRewards[0];
|
||||||
const stillValid =
|
const stillValid =
|
||||||
winner &&
|
winner && existing.memberId === winner.memberId && winner.current > 0;
|
||||||
existing.memberId === winner.memberId &&
|
|
||||||
winner.current > 0;
|
|
||||||
if (!stillValid && existing.status !== "claimed") {
|
if (!stillValid && existing.status !== "claimed") {
|
||||||
try { await pb.delete("rewards", existing.id); } catch {}
|
try {
|
||||||
|
await pb.delete("rewards", existing.id);
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1653,13 +1675,26 @@ app.post("/api/completions/toggle", requireDeviceToken, async (c) => {
|
|||||||
if (!assignedChoreId || !date) {
|
if (!assignedChoreId || !date) {
|
||||||
return c.json({ error: "assignedChoreId and date required" }, 400);
|
return c.json({ error: "assignedChoreId and date required" }, 400);
|
||||||
}
|
}
|
||||||
const nextDay = new Date(new Date(date + "T00:00:00Z").getTime() + 86400000)
|
// Todos are one-off: any existing completion means it's done, regardless of date.
|
||||||
.toISOString()
|
const chore = await pb
|
||||||
.slice(0, 10);
|
.getList(
|
||||||
const existing = await pb.getList(
|
"assigned_chores",
|
||||||
"completions",
|
`famId = '${famId}' && id = '${assignedChoreId}'`,
|
||||||
`assignedChoreId = '${assignedChoreId}' && memberId = '${memberId}' && date >= '${date}' && date < '${nextDay}'`,
|
)
|
||||||
);
|
.then((r) => r.items?.[0]);
|
||||||
|
const isTodo = chore?.isTodo;
|
||||||
|
let filter: string;
|
||||||
|
if (isTodo) {
|
||||||
|
filter = `assignedChoreId = '${assignedChoreId}' && memberId = '${memberId}'`;
|
||||||
|
} else {
|
||||||
|
// Scope to the chore's period: daily = today, weekly = the current week.
|
||||||
|
// Otherwise a weekly chore completed yesterday would be toggleable again today.
|
||||||
|
const payday = await getFamPayday(famId);
|
||||||
|
const tz = await getFamTimezone(famId);
|
||||||
|
const { from, to } = periodWindow(chore?.frequency, payday, tz);
|
||||||
|
filter = `assignedChoreId = '${assignedChoreId}' && memberId = '${memberId}' && date >= '${from}' && date < '${to}'`;
|
||||||
|
}
|
||||||
|
const existing = await pb.getList("completions", filter);
|
||||||
if (existing.items?.length > 0) {
|
if (existing.items?.length > 0) {
|
||||||
await pb.delete("completions", existing.items[0].id);
|
await pb.delete("completions", existing.items[0].id);
|
||||||
evaluateFam(famId).catch(() => {});
|
evaluateFam(famId).catch(() => {});
|
||||||
@@ -1670,6 +1705,7 @@ app.post("/api/completions/toggle", requireDeviceToken, async (c) => {
|
|||||||
memberId,
|
memberId,
|
||||||
assignedChoreId,
|
assignedChoreId,
|
||||||
date,
|
date,
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
evaluateFam(famId).catch(() => {});
|
evaluateFam(famId).catch(() => {});
|
||||||
return c.json({ completed: true, record });
|
return c.json({ completed: true, record });
|
||||||
@@ -1842,7 +1878,7 @@ app.post("/api/admin/:famId/rewards/issue-all", requireAdmin, async (c) => {
|
|||||||
try {
|
try {
|
||||||
const { famId } = c.req.param();
|
const { famId } = c.req.param();
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
const { memberId, message } = body;
|
const { memberId } = body;
|
||||||
if (!memberId) return c.json({ error: "memberId required" }, 400);
|
if (!memberId) return c.json({ error: "memberId required" }, 400);
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
// Find all claimable rewards for this member (unclaimed or requested)
|
// Find all claimable rewards for this member (unclaimed or requested)
|
||||||
@@ -1855,15 +1891,6 @@ app.post("/api/admin/:famId/rewards/issue-all", requireAdmin, async (c) => {
|
|||||||
await pb.update("rewards", r.id, { status: "claimed", claimedAt: now });
|
await pb.update("rewards", r.id, { status: "claimed", claimedAt: now });
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
// Send notification to member if message provided
|
|
||||||
if (message && count > 0) {
|
|
||||||
await pb.create("notifications", {
|
|
||||||
famId,
|
|
||||||
memberId,
|
|
||||||
message,
|
|
||||||
read: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return c.json({ count });
|
return c.json({ count });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return handleError(c, err);
|
return handleError(c, err);
|
||||||
@@ -1893,8 +1920,6 @@ app.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Admin: Send notification message ────────────────────
|
|
||||||
|
|
||||||
app.get("/api/admin/:famId/profile", requireAdmin, async (c) => {
|
app.get("/api/admin/:famId/profile", requireAdmin, async (c) => {
|
||||||
try {
|
try {
|
||||||
const { famId } = c.req.param();
|
const { famId } = c.req.param();
|
||||||
@@ -1943,25 +1968,6 @@ app.patch("/api/admin/:famId/profile", requireAdmin, async (c) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/admin/:famId/send-message", requireAdmin, async (c) => {
|
|
||||||
try {
|
|
||||||
const { famId } = c.req.param();
|
|
||||||
const body = await c.req.json();
|
|
||||||
if (!body.memberId || !body.message) {
|
|
||||||
return c.json({ error: "memberId and message required" }, 400);
|
|
||||||
}
|
|
||||||
const record = await pb.create("notifications", {
|
|
||||||
famId,
|
|
||||||
memberId: body.memberId,
|
|
||||||
message: body.message,
|
|
||||||
read: false,
|
|
||||||
});
|
|
||||||
return c.json(record);
|
|
||||||
} catch (err) {
|
|
||||||
return handleError(c, err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Member: Claim a reward ─────────────────────────────
|
// ── Member: Claim a reward ─────────────────────────────
|
||||||
|
|
||||||
app.post("/api/members/rewards/:id/claim", requireDeviceToken, async (c) => {
|
app.post("/api/members/rewards/:id/claim", requireDeviceToken, async (c) => {
|
||||||
@@ -1971,7 +1977,10 @@ app.post("/api/members/rewards/:id/claim", requireDeviceToken, async (c) => {
|
|||||||
const memberId = c.get("memberId");
|
const memberId = c.get("memberId");
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const tz = await getFamTimezone(famId);
|
const tz = await getFamTimezone(famId);
|
||||||
const found = await pb.getList("rewards", `famId = '${famId}' && id = '${id}'`);
|
const found = await pb.getList(
|
||||||
|
"rewards",
|
||||||
|
`famId = '${famId}' && id = '${id}'`,
|
||||||
|
);
|
||||||
const reward = found.items?.[0];
|
const reward = found.items?.[0];
|
||||||
if (!reward) return c.json({ error: "Reward not found" }, 404);
|
if (!reward) return c.json({ error: "Reward not found" }, 404);
|
||||||
try {
|
try {
|
||||||
@@ -1986,19 +1995,6 @@ app.post("/api/members/rewards/:id/claim", requireDeviceToken, async (c) => {
|
|||||||
status: "requested",
|
status: "requested",
|
||||||
requestedAt: now,
|
requestedAt: now,
|
||||||
});
|
});
|
||||||
// Create notification for admin
|
|
||||||
const members = await pb.getList(
|
|
||||||
"members",
|
|
||||||
`famId = '${famId}' && id = '${memberId}'`,
|
|
||||||
);
|
|
||||||
const memberName = members.items?.[0]?.name || "Unknown";
|
|
||||||
const msg = `Claim requested by ${memberName}`;
|
|
||||||
await pb.create("notifications", {
|
|
||||||
famId,
|
|
||||||
memberId,
|
|
||||||
message: msg,
|
|
||||||
read: false,
|
|
||||||
});
|
|
||||||
return c.json(record);
|
return c.json(record);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return handleError(c, err);
|
return handleError(c, err);
|
||||||
@@ -2031,56 +2027,12 @@ app.post("/api/members/rewards/request-all", requireDeviceToken, async (c) => {
|
|||||||
});
|
});
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
if (count > 0) {
|
|
||||||
const members = await pb.getList(
|
|
||||||
"members",
|
|
||||||
`famId = '${famId}' && id = '${memberId}'`,
|
|
||||||
);
|
|
||||||
const memberName = members.items?.[0]?.name || "Unknown";
|
|
||||||
const msg = `${count} claim(s) requested by ${memberName}`;
|
|
||||||
await pb.create("notifications", {
|
|
||||||
famId,
|
|
||||||
memberId,
|
|
||||||
message: msg,
|
|
||||||
read: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return c.json({ count });
|
return c.json({ count });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return handleError(c, err);
|
return handleError(c, err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Member: Notifications ──────────────────────────────
|
|
||||||
|
|
||||||
app.get("/api/members/notifications", requireDeviceToken, async (c) => {
|
|
||||||
try {
|
|
||||||
const famId = c.get("famId");
|
|
||||||
const memberId = c.get("memberId");
|
|
||||||
const data = await pb.getList(
|
|
||||||
"notifications",
|
|
||||||
`famId = '${famId}' && memberId = '${memberId}'`,
|
|
||||||
);
|
|
||||||
return c.json(data.items);
|
|
||||||
} catch (err) {
|
|
||||||
return handleError(c, err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post(
|
|
||||||
"/api/members/notifications/:id/dismiss",
|
|
||||||
requireDeviceToken,
|
|
||||||
async (c) => {
|
|
||||||
try {
|
|
||||||
const { id } = c.req.param();
|
|
||||||
const record = await pb.update("notifications", id, { read: true });
|
|
||||||
return c.json(record);
|
|
||||||
} catch (err) {
|
|
||||||
return handleError(c, err);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Payday: release this period's unpaid cash as an auto-claimed ask ──
|
// ── Payday: release this period's unpaid cash as an auto-claimed ask ──
|
||||||
|
|
||||||
async function releaseWeek(famId: string) {
|
async function releaseWeek(famId: string) {
|
||||||
@@ -2097,7 +2049,12 @@ async function releaseWeek(famId: string) {
|
|||||||
const paydayTime = fam.paydayTime || "18:00";
|
const paydayTime = fam.paydayTime || "18:00";
|
||||||
const target = new Date(wallClockToUtc(wsToday, paydayTime, tz));
|
const target = new Date(wallClockToUtc(wsToday, paydayTime, tz));
|
||||||
if (Date.now() < target.getTime()) {
|
if (Date.now() < target.getTime()) {
|
||||||
return { settled: false, notYet: true, weekStart: wsToday, target: target.toISOString() };
|
return {
|
||||||
|
settled: false,
|
||||||
|
notYet: true,
|
||||||
|
weekStart: wsToday,
|
||||||
|
target: target.toISOString(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fam.lastIssued === wsToday) return { settled: false, weekStart: wsToday };
|
if (fam.lastIssued === wsToday) return { settled: false, weekStart: wsToday };
|
||||||
@@ -2119,7 +2076,10 @@ async function releaseWeek(famId: string) {
|
|||||||
const unpaid = (cashRewards.items || []).filter(
|
const unpaid = (cashRewards.items || []).filter(
|
||||||
(r: any) => r.memberId === m.id && r.status !== "claimed",
|
(r: any) => r.memberId === m.id && r.status !== "claimed",
|
||||||
);
|
);
|
||||||
const total = unpaid.reduce((sum: number, r: any) => sum + Number(r.value), 0);
|
const total = unpaid.reduce(
|
||||||
|
(sum: number, r: any) => sum + Number(r.value),
|
||||||
|
0,
|
||||||
|
);
|
||||||
if (total > 0) {
|
if (total > 0) {
|
||||||
// Auto-claim: flip every unpaid cash reward to 'requested' so they land on
|
// Auto-claim: flip every unpaid cash reward to 'requested' so they land on
|
||||||
// the parent's Issue list, but keep them as individual rows (Issue All totals).
|
// the parent's Issue list, but keep them as individual rows (Issue All totals).
|
||||||
@@ -2132,20 +2092,15 @@ async function releaseWeek(famId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Kid notice: the fun moment.
|
|
||||||
try {
|
|
||||||
await pb.create("notifications", {
|
|
||||||
famId,
|
|
||||||
memberId: m.id,
|
|
||||||
message: `You've earned £${total.toFixed(2)}! Go get it from your parent ⭐`,
|
|
||||||
read: false,
|
|
||||||
});
|
|
||||||
} catch {}
|
|
||||||
breakdown.push({
|
breakdown.push({
|
||||||
memberId: m.id,
|
memberId: m.id,
|
||||||
name: m.name,
|
name: m.name,
|
||||||
total,
|
total,
|
||||||
rewards: unpaid.map((r: any) => ({ id: r.id, label: r.label, value: Number(r.value) })),
|
rewards: unpaid.map((r: any) => ({
|
||||||
|
id: r.id,
|
||||||
|
label: r.label,
|
||||||
|
value: Number(r.value),
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2359,13 +2314,150 @@ app.post("/api/admin/:famId/debug/generate-data", requireAdmin, async (c) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Chat ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Resolve the acting user for a chat write. Admins authenticate via the
|
||||||
|
// httpOnly `session` cookie (parsed server-side) or explicit session headers
|
||||||
|
// (server-to-server); members via device token.
|
||||||
|
async function resolveChatActor(c: any) {
|
||||||
|
// Server-side admin (layout load forwards session headers).
|
||||||
|
const hsFamId = c.req.header("x-session-famid");
|
||||||
|
const hsUserId = c.req.header("x-session-userid");
|
||||||
|
if (hsFamId && hsUserId) {
|
||||||
|
const admins = await pb.getList(
|
||||||
|
"fam_admins",
|
||||||
|
`famId = '${hsFamId}' && userId = '${hsUserId}'`,
|
||||||
|
);
|
||||||
|
const admin = admins.items?.[0];
|
||||||
|
if (admin) {
|
||||||
|
return {
|
||||||
|
famId: hsFamId,
|
||||||
|
actor: {
|
||||||
|
id: admin.id,
|
||||||
|
type: "admin",
|
||||||
|
name: admin.name,
|
||||||
|
color: admin.color,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const cookieHeader = c.req.header("cookie") || "";
|
||||||
|
const cookieMatch = cookieHeader.match(/session=([^;]+)/);
|
||||||
|
if (cookieMatch) {
|
||||||
|
try {
|
||||||
|
const s = JSON.parse(decodeURIComponent(cookieMatch[1]));
|
||||||
|
if (s?.famId && s?.userId) {
|
||||||
|
const admins = await pb.getList(
|
||||||
|
"fam_admins",
|
||||||
|
`famId = '${s.famId}' && userId = '${s.userId}'`,
|
||||||
|
);
|
||||||
|
const admin = admins.items?.[0];
|
||||||
|
if (admin) {
|
||||||
|
return {
|
||||||
|
famId: s.famId,
|
||||||
|
actor: {
|
||||||
|
id: admin.id,
|
||||||
|
type: "admin",
|
||||||
|
name: admin.name,
|
||||||
|
color: admin.color,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
const famId = c.req.header("x-device-famid");
|
||||||
|
const deviceToken = c.req.header("x-device-token");
|
||||||
|
if (famId && deviceToken) {
|
||||||
|
const hash = crypto.createHash("sha256").update(deviceToken).digest("hex");
|
||||||
|
const members = await pb.getList(
|
||||||
|
"members",
|
||||||
|
`famId = '${famId}' && deviceToken = '${hash}'`,
|
||||||
|
);
|
||||||
|
const member = members.items?.[0];
|
||||||
|
if (member) {
|
||||||
|
return {
|
||||||
|
famId,
|
||||||
|
actor: {
|
||||||
|
id: member.id,
|
||||||
|
type: "member",
|
||||||
|
name: member.name,
|
||||||
|
color: member.color,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.post("/api/chat/:famId/messages", async (c) => {
|
||||||
|
try {
|
||||||
|
const auth = await resolveChatActor(c);
|
||||||
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
const { content } = await c.req.json();
|
||||||
|
if (!content || !content.trim()) {
|
||||||
|
return c.json({ error: "content required" }, 400);
|
||||||
|
}
|
||||||
|
const record = await pb.create("messages", {
|
||||||
|
famId: auth.famId,
|
||||||
|
authorType: auth.actor.type,
|
||||||
|
authorId: auth.actor.id,
|
||||||
|
authorName: auth.actor.name,
|
||||||
|
authorColor: auth.actor.color,
|
||||||
|
content: content.trim(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
return c.json(record);
|
||||||
|
} catch (err) {
|
||||||
|
return handleError(c, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/chat/:famId/typing", async (c) => {
|
||||||
|
try {
|
||||||
|
const auth = await resolveChatActor(c);
|
||||||
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
const { typing } = await c.req.json();
|
||||||
|
const existing = await pb.getList(
|
||||||
|
"chat_typing",
|
||||||
|
`famId = '${auth.famId}' && actorId = '${auth.actor.id}' && actorType = '${auth.actor.type}'`,
|
||||||
|
);
|
||||||
|
const row = {
|
||||||
|
famId: auth.famId,
|
||||||
|
actorId: auth.actor.id,
|
||||||
|
actorType: auth.actor.type,
|
||||||
|
authorName: auth.actor.name,
|
||||||
|
authorColor: auth.actor.color,
|
||||||
|
typing: !!typing,
|
||||||
|
};
|
||||||
|
if (existing.items?.length) {
|
||||||
|
await pb.update("chat_typing", existing.items[0].id, row);
|
||||||
|
} else {
|
||||||
|
await pb.create("chat_typing", row);
|
||||||
|
}
|
||||||
|
return c.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
return handleError(c, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Current user's chat identity (name, color, actorId) + famId.
|
||||||
|
app.get("/api/chat/me", async (c) => {
|
||||||
|
try {
|
||||||
|
const auth = await resolveChatActor(c);
|
||||||
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
return c.json({ famId: auth.famId, actor: auth.actor });
|
||||||
|
} catch (err) {
|
||||||
|
return handleError(c, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── Start server ─────────────────────────────────────────
|
// ── Start server ─────────────────────────────────────────
|
||||||
|
|
||||||
const port = parseInt(process.env.PROXY_PORT || "3456", 10);
|
const port = parseInt(process.env.PROXY_PORT || PROXY_PORT, 10);
|
||||||
const SERVER_IP = "192.168.1.225";
|
|
||||||
|
|
||||||
serve({ fetch: app.fetch, port }, async (info) => {
|
serve({ fetch: app.fetch, port }, async (info) => {
|
||||||
console.log(`Hono proxy listening on ${SERVER_IP}:${info.port}`);
|
console.log(`Hono proxy listening on 0.0.0.0:${info.port}`);
|
||||||
try {
|
try {
|
||||||
await migrate();
|
await migrate();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
+360
-52
@@ -1,6 +1,4 @@
|
|||||||
import { SERVER_IP, PB_PORT, PB_EMAIL, PB_PASSWORD } from "../../config.ts";
|
import { PB_ENDPOINT, PB_EMAIL, PB_PASSWORD } from "./env.ts";
|
||||||
|
|
||||||
const PB_ENDPOINT = `http://${SERVER_IP}:${PB_PORT}`;
|
|
||||||
|
|
||||||
let token: string | null = null;
|
let token: string | null = null;
|
||||||
|
|
||||||
@@ -32,7 +30,7 @@ async function getCollection(name: string): Promise<any | null> {
|
|||||||
return data?.items?.[0] || null;
|
return data?.items?.[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createCollection(col: any): Promise<void> {
|
async function createCollection(col: any): Promise<string | null> {
|
||||||
const t = await auth();
|
const t = await auth();
|
||||||
const res = await fetch(`${PB_ENDPOINT}/api/collections`, {
|
const res = await fetch(`${PB_ENDPOINT}/api/collections`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -46,6 +44,7 @@ async function createCollection(col: any): Promise<void> {
|
|||||||
if (!res.ok)
|
if (!res.ok)
|
||||||
throw new Error(`Create ${col.name} failed: ${JSON.stringify(data)}`);
|
throw new Error(`Create ${col.name} failed: ${JSON.stringify(data)}`);
|
||||||
console.log(` ✓ Created collection: ${col.name}`);
|
console.log(` ✓ Created collection: ${col.name}`);
|
||||||
|
return data?.id || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateCollection(id: string, col: any): Promise<void> {
|
async function updateCollection(id: string, col: any): Promise<void> {
|
||||||
@@ -64,9 +63,239 @@ async function updateCollection(id: string, col: any): Promise<void> {
|
|||||||
console.log(` ✓ Updated collection: ${col.name || id}`);
|
console.log(` ✓ Updated collection: ${col.name || id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Field helpers (mirror proxy/scripts/seed.ts) ──
|
||||||
|
function text(name: string, required = false) {
|
||||||
|
return { name, type: "text", required };
|
||||||
|
}
|
||||||
|
function uniqueText(name: string) {
|
||||||
|
return { name, type: "text", required: true, unique: true };
|
||||||
|
}
|
||||||
|
function number(name: string, required = false) {
|
||||||
|
return { name, type: "number", required };
|
||||||
|
}
|
||||||
|
function bool(name: string) {
|
||||||
|
return { name, type: "bool" };
|
||||||
|
}
|
||||||
|
function date(name: string) {
|
||||||
|
return { name, type: "date" };
|
||||||
|
}
|
||||||
|
function jsonField(name: string) {
|
||||||
|
return { name, type: "json" };
|
||||||
|
}
|
||||||
|
function select(name: string, values: string[], required = false) {
|
||||||
|
return { name, type: "select", required, values, maxSelect: 1 };
|
||||||
|
}
|
||||||
|
function rel(name: string, collectionId: string, required = false) {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
type: "relation",
|
||||||
|
required,
|
||||||
|
collectionId,
|
||||||
|
maxSelect: 1,
|
||||||
|
cascadeDelete: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function colDef(name: string, fields: any[]): any {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
type: "base",
|
||||||
|
listRule: "",
|
||||||
|
viewRule: "",
|
||||||
|
createRule: null,
|
||||||
|
updateRule: null,
|
||||||
|
deleteRule: null,
|
||||||
|
fields,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bootstrap the full base schema on a fresh PocketBase (docker first boot).
|
||||||
|
// Idempotent — skips collections that already exist.
|
||||||
|
async function ensureSchema(): Promise<void> {
|
||||||
|
if (await getCollection("fams")) {
|
||||||
|
console.log("[migrate] Base schema already present — skipping bootstrap.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log("[migrate] Bootstrapping base schema on fresh PocketBase...");
|
||||||
|
|
||||||
|
// Pass 1: independent
|
||||||
|
const famsId = (await createCollection(
|
||||||
|
colDef("fams", [
|
||||||
|
text("name", true),
|
||||||
|
uniqueText("slug"),
|
||||||
|
text("inviteCode"),
|
||||||
|
text("stripeCustomerId"),
|
||||||
|
jsonField("featureFlags"),
|
||||||
|
jsonField("seasons"),
|
||||||
|
]),
|
||||||
|
))!;
|
||||||
|
// fams is sensitive → superadmin-only (proxy/server reads). Not public.
|
||||||
|
await updateCollection(famsId, { viewRule: null, listRule: null });
|
||||||
|
await createCollection(
|
||||||
|
colDef("bonus_templates", [
|
||||||
|
rel("famId", famsId, true),
|
||||||
|
text("name", true),
|
||||||
|
text("description"),
|
||||||
|
select("target", ["individual", "competitive", "collaborative"], true),
|
||||||
|
select("type", ["threshold", "count", "manual"], true),
|
||||||
|
select("occurrence", ["recurring", "once"], true),
|
||||||
|
select("rewardType", ["points", "cash", "prize"], true),
|
||||||
|
text("rewardValue", true),
|
||||||
|
number("criteriaValue"),
|
||||||
|
select("period", ["schedule", "daily", "weekly", "monthly"]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pass 2: reference fams
|
||||||
|
await createCollection(
|
||||||
|
colDef("settings", [rel("famId", famsId, true), text("webhookUrl")]),
|
||||||
|
);
|
||||||
|
const membersId = (await createCollection(
|
||||||
|
colDef("members", [
|
||||||
|
rel("famId", famsId, true),
|
||||||
|
text("name", true),
|
||||||
|
text("color"),
|
||||||
|
text("deviceToken"),
|
||||||
|
text("deviceTokenHint"),
|
||||||
|
]),
|
||||||
|
))!;
|
||||||
|
const choreTemplatesId = (await createCollection(
|
||||||
|
colDef("chore_templates", [
|
||||||
|
rel("famId", famsId, true),
|
||||||
|
text("name", true),
|
||||||
|
text("description"),
|
||||||
|
select("defaultFrequency", ["daily", "weekly"], true),
|
||||||
|
select("defaultType", ["points", "money"], true),
|
||||||
|
number("defaultValue", true),
|
||||||
|
]),
|
||||||
|
))!;
|
||||||
|
await createCollection(
|
||||||
|
colDef("fam_admins", [
|
||||||
|
rel("famId", famsId, true),
|
||||||
|
text("userId", true),
|
||||||
|
text("email", true),
|
||||||
|
text("name"),
|
||||||
|
text("color"),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const bonusConfigsId = (await createCollection(
|
||||||
|
colDef("bonus_configs", [
|
||||||
|
rel("famId", famsId, true),
|
||||||
|
text("name", true),
|
||||||
|
text("description"),
|
||||||
|
select("target", ["individual", "competitive", "collaborative"], true),
|
||||||
|
select("type", ["threshold", "count", "manual"], true),
|
||||||
|
select("occurrence", ["recurring", "once"], true),
|
||||||
|
select("rewardType", ["points", "cash", "prize"], true),
|
||||||
|
text("rewardValue", true),
|
||||||
|
number("criteriaValue"),
|
||||||
|
rel("memberId", membersId),
|
||||||
|
select("period", ["schedule", "daily", "weekly", "monthly"]),
|
||||||
|
select("status", ["active", "completed"], true),
|
||||||
|
]),
|
||||||
|
))!;
|
||||||
|
|
||||||
|
// Pass 3: nested deps
|
||||||
|
await createCollection(
|
||||||
|
colDef("weekly_history", [
|
||||||
|
rel("famId", famsId, true),
|
||||||
|
rel("memberId", membersId, true),
|
||||||
|
date("weekStart"),
|
||||||
|
number("pointsEarned"),
|
||||||
|
number("moneyEarned"),
|
||||||
|
number("choresCompleted"),
|
||||||
|
number("bonusEarned"),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
await createCollection(
|
||||||
|
colDef("rewards", [
|
||||||
|
rel("famId", famsId, true),
|
||||||
|
rel("memberId", membersId, true),
|
||||||
|
rel("bonusConfigId", bonusConfigsId),
|
||||||
|
text("label", true),
|
||||||
|
number("value", true),
|
||||||
|
select("rewardType", ["cash", "prize", "points"], true),
|
||||||
|
select("status", ["unclaimed", "requested", "claimed"], true),
|
||||||
|
select("claimable", ["immediate", "payday"], true),
|
||||||
|
text("settleDate"),
|
||||||
|
date("claimedAt"),
|
||||||
|
date("requestedAt"),
|
||||||
|
text("date"),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const assignedChoresId = (await createCollection(
|
||||||
|
colDef("assigned_chores", [
|
||||||
|
rel("famId", famsId, true),
|
||||||
|
rel("memberId", membersId, true),
|
||||||
|
rel("templateId", choreTemplatesId, true),
|
||||||
|
select("frequency", ["daily", "weekly"], true),
|
||||||
|
select("type", ["points", "money"], true),
|
||||||
|
number("value", true),
|
||||||
|
text("customName"),
|
||||||
|
jsonField("seasonIds"),
|
||||||
|
]),
|
||||||
|
))!;
|
||||||
|
await createCollection(
|
||||||
|
colDef("seasons", [
|
||||||
|
rel("famId", famsId, true),
|
||||||
|
text("name", true),
|
||||||
|
text("color"),
|
||||||
|
bool("active"),
|
||||||
|
date("autoDisable"),
|
||||||
|
date("autoStart"),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
await createCollection(
|
||||||
|
colDef("completions", [
|
||||||
|
rel("famId", famsId, true),
|
||||||
|
rel("memberId", membersId, true),
|
||||||
|
rel("assignedChoreId", assignedChoresId, true),
|
||||||
|
date("date"),
|
||||||
|
date("completedAt"),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
await createCollection(
|
||||||
|
colDef("messages", [
|
||||||
|
rel("famId", famsId, true),
|
||||||
|
select("authorType", ["admin", "member"], true),
|
||||||
|
text("authorId", true),
|
||||||
|
text("authorName", true),
|
||||||
|
text("authorColor"),
|
||||||
|
text("content", true),
|
||||||
|
date("createdAt"),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
await createCollection(
|
||||||
|
colDef("chat_typing", [
|
||||||
|
rel("famId", famsId, true),
|
||||||
|
text("actorId", true),
|
||||||
|
select("actorType", ["admin", "member"], true),
|
||||||
|
text("authorName", true),
|
||||||
|
text("authorColor"),
|
||||||
|
bool("typing"),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
console.log("[migrate] Base schema bootstrapped.");
|
||||||
|
}
|
||||||
|
|
||||||
export async function migrate(): Promise<void> {
|
export async function migrate(): Promise<void> {
|
||||||
console.log("[migrate] Checking PB collection schemas...");
|
console.log("[migrate] Checking PB collection schemas...");
|
||||||
|
|
||||||
|
await ensureSchema();
|
||||||
|
|
||||||
|
// ── 0. Lock fams to superadmin-only (sensitive; read via proxy/server) ──
|
||||||
|
{
|
||||||
|
const famsCol = await getCollection("fams");
|
||||||
|
if (famsCol) {
|
||||||
|
const rules = { viewRule: null, listRule: null };
|
||||||
|
if (famsCol.viewRule !== null || famsCol.listRule !== null) {
|
||||||
|
console.log("[migrate] Locking fams collection to superadmin-only...");
|
||||||
|
await updateCollection(famsCol.id, rules);
|
||||||
|
} else {
|
||||||
|
console.log(" ↳ fams already superadmin-only");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── 1. Create bonus_configs if missing ──
|
// ── 1. Create bonus_configs if missing ──
|
||||||
const existing = await getCollection("bonus_configs");
|
const existing = await getCollection("bonus_configs");
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
@@ -302,7 +531,9 @@ export async function migrate(): Promise<void> {
|
|||||||
missing.push({ name: "settleDate", type: "text", required: false });
|
missing.push({ name: "settleDate", type: "text", required: false });
|
||||||
}
|
}
|
||||||
if (missing.length) {
|
if (missing.length) {
|
||||||
console.log("[migrate] Adding rewards.claimable/settleDate (payday gating)...");
|
console.log(
|
||||||
|
"[migrate] Adding rewards.claimable/settleDate (payday gating)...",
|
||||||
|
);
|
||||||
rewardsCol2.fields.push(...missing);
|
rewardsCol2.fields.push(...missing);
|
||||||
await updateCollection(rewardsCol2.id, {
|
await updateCollection(rewardsCol2.id, {
|
||||||
name: "rewards",
|
name: "rewards",
|
||||||
@@ -370,47 +601,6 @@ export async function migrate(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 4. Create notifications collection if missing ──
|
|
||||||
const notifCol = await getCollection("notifications");
|
|
||||||
if (!notifCol) {
|
|
||||||
const famsCol = await getCollection("fams");
|
|
||||||
if (!famsCol) throw new Error("fams collection not found");
|
|
||||||
const membersCol = await getCollection("members");
|
|
||||||
if (!membersCol) throw new Error("members collection not found");
|
|
||||||
console.log("[migrate] Creating notifications collection...");
|
|
||||||
await createCollection({
|
|
||||||
name: "notifications",
|
|
||||||
type: "base",
|
|
||||||
listRule: "",
|
|
||||||
viewRule: "",
|
|
||||||
createRule: null,
|
|
||||||
updateRule: null,
|
|
||||||
deleteRule: null,
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: "famId",
|
|
||||||
type: "relation",
|
|
||||||
required: true,
|
|
||||||
collectionId: famsCol.id,
|
|
||||||
maxSelect: 1,
|
|
||||||
cascadeDelete: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "memberId",
|
|
||||||
type: "relation",
|
|
||||||
required: true,
|
|
||||||
collectionId: membersCol.id,
|
|
||||||
maxSelect: 1,
|
|
||||||
cascadeDelete: false,
|
|
||||||
},
|
|
||||||
{ name: "message", type: "text", required: true },
|
|
||||||
{ name: "read", type: "bool", required: false },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.log(` ↳ notifications already exists`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 5. Add payday field to fams if missing ──
|
// ── 5. Add payday field to fams if missing ──
|
||||||
const famsCol = await getCollection("fams");
|
const famsCol = await getCollection("fams");
|
||||||
if (famsCol) {
|
if (famsCol) {
|
||||||
@@ -547,10 +737,15 @@ export async function migrate(): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (fams.length) {
|
if (fams.length) {
|
||||||
console.log(` ✓ Backfilled timezone="auto" for ${fams.length} fam${fams.length > 1 ? "s" : ""}`);
|
console.log(
|
||||||
|
` ✓ Backfilled timezone="auto" for ${fams.length} fam${fams.length > 1 ? "s" : ""}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(" ↳ timezone backfill skipped:", err instanceof Error ? err.message : err);
|
console.log(
|
||||||
|
" ↳ timezone backfill skipped:",
|
||||||
|
err instanceof Error ? err.message : err,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 6. Backfill completions.date — strip timestamps to YYYY-MM-DD ──
|
// ── 6. Backfill completions.date — strip timestamps to YYYY-MM-DD ──
|
||||||
@@ -1340,9 +1535,13 @@ export async function migrate(): Promise<void> {
|
|||||||
let needsUpdate = false;
|
let needsUpdate = false;
|
||||||
|
|
||||||
// 7a. Make templateId non-required (todos don't use templates)
|
// 7a. Make templateId non-required (todos don't use templates)
|
||||||
const tplField = assignedCol.fields.find((f: any) => f.name === "templateId");
|
const tplField = assignedCol.fields.find(
|
||||||
|
(f: any) => f.name === "templateId",
|
||||||
|
);
|
||||||
if (tplField && tplField.required) {
|
if (tplField && tplField.required) {
|
||||||
console.log("[migrate] Making assigned_chores.templateId non-required...");
|
console.log(
|
||||||
|
"[migrate] Making assigned_chores.templateId non-required...",
|
||||||
|
);
|
||||||
tplField.required = false;
|
tplField.required = false;
|
||||||
needsUpdate = true;
|
needsUpdate = true;
|
||||||
}
|
}
|
||||||
@@ -1354,7 +1553,7 @@ export async function migrate(): Promise<void> {
|
|||||||
assignedCol.fields.push(
|
assignedCol.fields.push(
|
||||||
{ name: "isTodo", type: "bool" },
|
{ name: "isTodo", type: "bool" },
|
||||||
{ name: "startDate", type: "text" },
|
{ name: "startDate", type: "text" },
|
||||||
{ name: "completeBy", type: "text" }
|
{ name: "completeBy", type: "text" },
|
||||||
);
|
);
|
||||||
needsUpdate = true;
|
needsUpdate = true;
|
||||||
} else {
|
} else {
|
||||||
@@ -1374,7 +1573,116 @@ export async function migrate(): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log(` ↳ assigned_chores collection not found (will be created by seed)`);
|
console.log(
|
||||||
|
` ↳ assigned_chores collection not found (will be created by seed)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 8. Add completedAt timestamp to completions ──
|
||||||
|
const complCol = await getCollection("completions");
|
||||||
|
if (complCol) {
|
||||||
|
const hasCompletedAt = complCol.fields.some(
|
||||||
|
(f: any) => f.name === "completedAt",
|
||||||
|
);
|
||||||
|
if (!hasCompletedAt) {
|
||||||
|
console.log("[migrate] Adding completions.completedAt...");
|
||||||
|
complCol.fields.push({
|
||||||
|
name: "completedAt",
|
||||||
|
type: "date",
|
||||||
|
required: false,
|
||||||
|
hidden: false,
|
||||||
|
});
|
||||||
|
await updateCollection(complCol.id, {
|
||||||
|
name: "completions",
|
||||||
|
type: "base",
|
||||||
|
listRule: complCol.listRule,
|
||||||
|
viewRule: complCol.viewRule,
|
||||||
|
createRule: complCol.createRule,
|
||||||
|
updateRule: complCol.updateRule,
|
||||||
|
deleteRule: complCol.deleteRule,
|
||||||
|
fields: complCol.fields,
|
||||||
|
});
|
||||||
|
console.log(" ✓ completions.completedAt added");
|
||||||
|
} else {
|
||||||
|
console.log(` ↳ completions.completedAt already exists`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
` ↳ completions collection not found (will be created by seed)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 9. Create chat collections (messages + chat_typing) ──
|
||||||
|
const famsColChat = await getCollection("fams");
|
||||||
|
if (famsColChat) {
|
||||||
|
// 9a. messages
|
||||||
|
const msgsCol = await getCollection("messages");
|
||||||
|
if (!msgsCol) {
|
||||||
|
console.log("[migrate] Creating messages collection...");
|
||||||
|
await createCollection({
|
||||||
|
name: "messages",
|
||||||
|
type: "base",
|
||||||
|
listRule: "",
|
||||||
|
viewRule: "",
|
||||||
|
createRule: null,
|
||||||
|
updateRule: null,
|
||||||
|
deleteRule: null,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "famId",
|
||||||
|
type: "relation",
|
||||||
|
required: true,
|
||||||
|
collectionId: famsColChat.id,
|
||||||
|
maxSelect: 1,
|
||||||
|
cascadeDelete: true,
|
||||||
|
},
|
||||||
|
{ name: "authorType", type: "select", required: true, values: ["admin", "member"], maxSelect: 1 },
|
||||||
|
{ name: "authorId", type: "text", required: true },
|
||||||
|
{ name: "authorName", type: "text", required: true },
|
||||||
|
{ name: "authorColor", type: "text", required: false },
|
||||||
|
{ name: "content", type: "text", required: true },
|
||||||
|
{ name: "createdAt", type: "date", required: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log(` ↳ messages already exists`);
|
||||||
|
await updateCollection("messages", { listRule: "", viewRule: "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9b. chat_typing (transient presence rows, one per actor)
|
||||||
|
const typingCol = await getCollection("chat_typing");
|
||||||
|
if (!typingCol) {
|
||||||
|
console.log("[migrate] Creating chat_typing collection...");
|
||||||
|
await createCollection({
|
||||||
|
name: "chat_typing",
|
||||||
|
type: "base",
|
||||||
|
listRule: "",
|
||||||
|
viewRule: "",
|
||||||
|
createRule: null,
|
||||||
|
updateRule: null,
|
||||||
|
deleteRule: null,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "famId",
|
||||||
|
type: "relation",
|
||||||
|
required: true,
|
||||||
|
collectionId: famsColChat.id,
|
||||||
|
maxSelect: 1,
|
||||||
|
cascadeDelete: true,
|
||||||
|
},
|
||||||
|
{ name: "actorId", type: "text", required: true },
|
||||||
|
{ name: "actorType", type: "select", required: true, values: ["admin", "member"], maxSelect: 1 },
|
||||||
|
{ name: "authorName", type: "text", required: true },
|
||||||
|
{ name: "authorColor", type: "text", required: false },
|
||||||
|
{ name: "typing", type: "bool", required: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log(` ↳ chat_typing already exists`);
|
||||||
|
await updateCollection("chat_typing", { listRule: "", viewRule: "" });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(` ↳ fams collection not found — chat collections deferred`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[migrate] Done");
|
console.log("[migrate] Done");
|
||||||
|
|||||||
+1
-3
@@ -1,6 +1,4 @@
|
|||||||
import { SERVER_IP, PB_PORT, PB_EMAIL, PB_PASSWORD } from "../../config.ts";
|
import { PB_ENDPOINT, PB_EMAIL, PB_PASSWORD } from "./env.ts";
|
||||||
|
|
||||||
const PB_ENDPOINT = `http://${SERVER_IP}:${PB_PORT}`;
|
|
||||||
|
|
||||||
let adminToken: string | null = null;
|
let adminToken: string | null = null;
|
||||||
let tokenExpiry = 0;
|
let tokenExpiry = 0;
|
||||||
|
|||||||
+47
-4
@@ -53,13 +53,17 @@ export function addDaysStr(dateStr: string, days: number): string {
|
|||||||
export function weekStart(payday: number, tz: string): string {
|
export function weekStart(payday: number, tz: string): string {
|
||||||
const today = todayInTz(tz);
|
const today = todayInTz(tz);
|
||||||
const wd = weekdayInTz(new Date(), tz);
|
const wd = weekdayInTz(new Date(), tz);
|
||||||
const back = ((wd - payday) % 7 + 7) % 7;
|
const back = (((wd - payday) % 7) + 7) % 7;
|
||||||
return addDaysStr(today, -back);
|
return addDaysStr(today, -back);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The first configured-payday day strictly after dateStr (used to gate
|
// The first configured-payday day strictly after dateStr (used to gate
|
||||||
// payday-settled bonus rewards). Weekly: periodEnd (weekStart+6) → next payday.
|
// payday-settled bonus rewards). Weekly: periodEnd (weekStart+6) → next payday.
|
||||||
export function nextPaydayAfter(dateStr: string, payday: number, tz: string): string {
|
export function nextPaydayAfter(
|
||||||
|
dateStr: string,
|
||||||
|
payday: number,
|
||||||
|
tz: string,
|
||||||
|
): string {
|
||||||
let d = addDaysStr(dateStr, 1);
|
let d = addDaysStr(dateStr, 1);
|
||||||
while (weekdayInTz(new Date(d + "T12:00:00Z"), tz) !== payday) {
|
while (weekdayInTz(new Date(d + "T12:00:00Z"), tz) !== payday) {
|
||||||
d = addDaysStr(d, 1);
|
d = addDaysStr(d, 1);
|
||||||
@@ -82,7 +86,11 @@ function monthEndStr(month?: string): string {
|
|||||||
return `${month}-${String(lastDay).padStart(2, "0")}`;
|
return `${month}-${String(lastDay).padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function periodStart(period: string, payday: number, tz: string): string {
|
export function periodStart(
|
||||||
|
period: string,
|
||||||
|
payday: number,
|
||||||
|
tz: string,
|
||||||
|
): string {
|
||||||
if (period === "daily") return todayInTz(tz);
|
if (period === "daily") return todayInTz(tz);
|
||||||
if (period === "weekly") return weekStart(payday, tz);
|
if (period === "weekly") return weekStart(payday, tz);
|
||||||
if (period === "monthly") return monthStartStr();
|
if (period === "monthly") return monthStartStr();
|
||||||
@@ -96,7 +104,42 @@ export function periodEnd(period: string, start: string): string {
|
|||||||
return start;
|
return start;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function wallClockToUtc(dateStr: string, time: string, tz: string): number {
|
// The completion window a chore of a given frequency is "done for" in the
|
||||||
|
// current period. Weekly chores are done once per week ([weekStart, +7)),
|
||||||
|
// daily chores once per day ([today, +1)). Half-open [from, to).
|
||||||
|
export function periodWindow(
|
||||||
|
frequency: string,
|
||||||
|
payday: number,
|
||||||
|
tz: string,
|
||||||
|
): { from: string; to: string } {
|
||||||
|
if (frequency === "weekly") {
|
||||||
|
const from = weekStart(payday, tz);
|
||||||
|
return { from, to: addDaysStr(from, 7) };
|
||||||
|
}
|
||||||
|
const from = todayInTz(tz);
|
||||||
|
return { from, to: addDaysStr(from, 1) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// True if any completion date falls within the current period window for the
|
||||||
|
// chore's frequency. Dates may be YYYY-MM-DD or ISO (time portion ignored).
|
||||||
|
export function isCompleteForPeriod(
|
||||||
|
frequency: string,
|
||||||
|
payday: number,
|
||||||
|
tz: string,
|
||||||
|
dates: string[],
|
||||||
|
): boolean {
|
||||||
|
const { from, to } = periodWindow(frequency, payday, tz);
|
||||||
|
return dates.some((d) => {
|
||||||
|
const day = (d || "").slice(0, 10);
|
||||||
|
return day >= from && day < to;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wallClockToUtc(
|
||||||
|
dateStr: string,
|
||||||
|
time: string,
|
||||||
|
tz: string,
|
||||||
|
): number {
|
||||||
const [y, m, d] = dateStr.split("-").map(Number);
|
const [y, m, d] = dateStr.split("-").map(Number);
|
||||||
const [h, min] = time.split(":").map(Number);
|
const [h, min] = time.split(":").map(Number);
|
||||||
let epoch = Date.UTC(y, m - 1, d, h, min);
|
let epoch = Date.UTC(y, m - 1, d, h, min);
|
||||||
|
|||||||
Reference in New Issue
Block a user