From 03dd8db1a2b04258d5058de44827725e7c58552b Mon Sep 17 00:00:00 2001 From: JCEEE <0xjceee@proton.me> Date: Fri, 7 Aug 2026 11:37:46 +0100 Subject: [PATCH] update compose to be coolifyable --- MEMORY.md | 14 + RULES.md | 6 + config.ts | 5 - docker-compose.yaml | 10 +- .../src/routes/[fam]/[username]/+page.svelte | 2 +- .../[fam]/[username]/settings/+page.svelte | 2 +- frontend/vite.config.ts | 4 + proxy/package.json | 2 +- proxy/scripts/seed.ts | 342 +----------------- proxy/src/index.ts | 4 +- proxy/src/migrate.ts | 213 +---------- proxy/tsconfig.json | 12 +- timezone.ts | 217 ----------- 13 files changed, 59 insertions(+), 774 deletions(-) delete mode 100644 config.ts delete mode 100644 timezone.ts diff --git a/MEMORY.md b/MEMORY.md index 0a91687..5b78b11 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -177,3 +177,17 @@ - **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. + +### 2026-08-06 — Added root `shared/` for cross-package code +- Created `shared/timezone.ts` (moved from root `timezone.ts`). Imported by `frontend/src/routes/[fam]/[username]/+page.svelte`, `.../settings/+page.svelte`, and `proxy/src/index.ts`. Deleted the root `timezone.ts`. +- Created `shared/pb/schema.ts` — single source of truth for the PocketBase schema + field builders (`SCHEMA_PLAN` ordered collection plan + `text/select/rel/...` helpers). Both `proxy/src/migrate.ts` (`ensureSchema`) and `proxy/scripts/seed.ts` now iterate `SCHEMA_PLAN`; kills the previous duplicated schema/field-helper definitions in both files. +- Reason: `timezone.ts` and the PB schema are consumed by more than one package; `shared/` is the root location both can reach. Rule added to RULES.md: shared code lives in `shared/`, never inside `frontend/` or `proxy/`. +- Note: proxy `tsc --noEmit` already errors on `.ts`-extension imports (`allowImportingTsExtensions` unset) — pre-existing, not from this change. Runtime uses esbuild (build) + tsx (dev), both of which bundle the `shared/` imports correctly. Verified `pnpm build` clean for both packages. + +### 2026-08-07 — `@shared/*` import alias (path alias, not a pnpm package) +- Moved `config.ts` → `shared/config.ts`. All `shared/` code is now imported as `@shared/*` instead of relative `../../shared/...`. +- This is a **path alias**, not a pnpm workspace package (`@shared` alone isn't a valid npm package name; a real package would need `@scope/name`). +- Proxy: `tsconfig.json` sets `paths: { "@shared/*": ["../shared/*"] }`; esbuild build adds `--alias:@shared=../shared`; tsx resolves via tsconfig paths. Proxy keeps `.ts` extensions (`@shared/config.ts`). +- Frontend: uses `kit.alias` in `vite.config.ts` (NOT `paths` in `frontend/tsconfig.json`, which SvelteKit warns against). Frontend imports shared files **without** the `.ts` extension (`@shared/timezone`) because `rewriteRelativeImportExtensions` only rewrites relative paths. +- Verified: proxy build + frontend build + `svelte-check` all clean for `@shared/*` (svelte-check still reports pre-existing `qrcode` types + CSS warnings). +- Docker note: runtime image only copies `frontend/build` + `proxy/dist` (both already bundle `shared/`), so `shared/` needn't be copied into the image. diff --git a/RULES.md b/RULES.md index 8fa821d..2510fa6 100644 --- a/RULES.md +++ b/RULES.md @@ -41,6 +41,12 @@ ## Monorepo - SvelteKit in `frontend/`, Hono in `proxy/` - `pnpm dev` / `pnpm build` at root runs both in parallel +- `shared/` at root for code shared across packages, imported as `@shared/*` (path alias): + - `shared/config.ts` — service ports (`FRONTEND_PORT`/`PROXY_PORT`/`PB_PORT`) + - `shared/timezone.ts` — timezone helpers (imported by frontend routes + proxy/src/index.ts) + - `shared/pb/schema.ts` — single source of truth for the PocketBase schema + field builders. Both `proxy/src/migrate.ts` (idempotent bootstrap) and `proxy/scripts/seed.ts` consume `SCHEMA_PLAN`; edit schema there, not in the consumers. +- **`@shared/*` alias:** wired per package — proxy via `tsconfig.json` `paths` + esbuild `--alias:@shared=../shared` (tsx reads tsconfig paths); frontend via `kit.alias` in `vite.config.ts` (don't set `paths` in `frontend/tsconfig.json` — SvelteKit warns). In the frontend, import shared files **without** the `.ts` extension (`@shared/timezone`), because `rewriteRelativeImportExtensions` only rewrites relative paths; the proxy keeps `.ts` extensions. +- **Shared-file convention:** keep shared code in `shared/` (root), never inside `frontend/` or `proxy/`. SvelteKit never imports `config.ts`. - Decisions tracked in `MEMORY.md` ## Environment Variables diff --git a/config.ts b/config.ts deleted file mode 100644 index 8070cf8..0000000 --- a/config.ts +++ /dev/null @@ -1,5 +0,0 @@ -// 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 PROXY_PORT = "3456"; -export const PB_PORT = "8090"; diff --git a/docker-compose.yaml b/docker-compose.yaml index bb3f9fa..801155c 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -4,11 +4,11 @@ services: context: . dockerfile: docker/Dockerfile ports: - - "3001:80" # nginx / app (public) - - "8091:8090" # PocketBase admin UI (loopback only — SSH tunnel) + - "${FRONTEND_PORT:-3001}:3001" # nginx / app (public) + - "${PB_PORT:-8091}:8090" # PocketBase admin UI (private network) volumes: - - "./pb_data:/app/pb_data" # PB DB persistence + - "${PB_DATA:-./pb_data}:/app/pb_data" # PB DB persistence environment: - PB_EMAIL: debug@famchamp.dev - PB_PASSWORD: debug123 + PB_EMAIL: ${PB_EMAIL} + PB_PASSWORD: ${PB_PASSWORD} restart: unless-stopped diff --git a/frontend/src/routes/[fam]/[username]/+page.svelte b/frontend/src/routes/[fam]/[username]/+page.svelte index 7f60f98..35b557d 100644 --- a/frontend/src/routes/[fam]/[username]/+page.svelte +++ b/frontend/src/routes/[fam]/[username]/+page.svelte @@ -17,7 +17,7 @@ periodStart, periodEnd, isCompleteForPeriod - } from '../../../../../timezone.ts'; + } from '@shared/timezone'; let { data } = $props(); diff --git a/frontend/src/routes/[fam]/[username]/settings/+page.svelte b/frontend/src/routes/[fam]/[username]/settings/+page.svelte index 9c96629..9f4839f 100644 --- a/frontend/src/routes/[fam]/[username]/settings/+page.svelte +++ b/frontend/src/routes/[fam]/[username]/settings/+page.svelte @@ -3,7 +3,7 @@ import { enhance } from '$app/forms'; import { famStore } from '$lib/stores/fam.svelte'; import { ViewHeader, CardGrid, Card, Button } from '$lib/components'; - import { COMMON_TIMEZONES } from '../../../../../../timezone.ts'; + import { COMMON_TIMEZONES } from '@shared/timezone'; import QRCode from 'qrcode'; let { data } = $props(); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 4613c50..5463cc3 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,5 +1,6 @@ import tailwindcss from '@tailwindcss/vite'; import { defineConfig } from 'vite'; +import { fileURLToPath, URL } from 'node:url'; import adapter from '@sveltejs/adapter-node'; import { sveltekit } from '@sveltejs/kit/vite'; @@ -13,6 +14,9 @@ export default defineConfig(() => { filename.split(/[/\\]/).includes('node_modules') ? undefined : true, experimental: { async: true } }, + alias: { + '@shared': fileURLToPath(new URL('../shared', import.meta.url)) + }, adapter: adapter(), experimental: { remoteFunctions: true, handleRenderingErrors: true }, csrf: { diff --git a/proxy/package.json b/proxy/package.json index 8062a0e..2320deb 100644 --- a/proxy/package.json +++ b/proxy/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "dev": "tsx watch --env-file-if-exists=../.env src/index.ts", - "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js", + "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --alias:@shared=../shared", "start": "node dist/index.js", "seed": "tsx --env-file-if-exists=../.env scripts/seed.ts" }, diff --git a/proxy/scripts/seed.ts b/proxy/scripts/seed.ts index 66681e1..9c0f470 100644 --- a/proxy/scripts/seed.ts +++ b/proxy/scripts/seed.ts @@ -1,29 +1,9 @@ +import { + SCHEMA_PLAN, + type CollectionDef, +} from "@shared/pb/schema.ts"; import { PB_ENDPOINT, PB_EMAIL, PB_PASSWORD } from "../src/env.ts"; -interface FieldDef { - name: string; - type: string; - required?: boolean; - unique?: boolean; - max?: number; - min?: number; - values?: string[]; - maxSelect?: number; - collectionId?: string; - cascadeDelete?: boolean; -} - -interface CollectionDef { - name: string; - type: string; - fields: FieldDef[]; - listRule?: string | null; - viewRule?: string | null; - createRule?: string | null; - updateRule?: string | null; - deleteRule?: string | null; -} - async function getSuperadminToken(): Promise { const res = await fetch( `${PB_ENDPOINT}/api/collections/_superusers/auth-with-password`, @@ -67,322 +47,16 @@ async function createCollection( return data.id; } -function text(name: string, required = false): FieldDef { - return { name, type: "text", required }; -} - -function uniqueText(name: string): FieldDef { - return { name, type: "text", required: true, unique: true }; -} - -function number(name: string, required = false): FieldDef { - return { name, type: "number", required }; -} - -function bool(name: string): FieldDef { - return { name, type: "bool" }; -} - -function date(name: string): FieldDef { - return { name, type: "date" }; -} - -function jsonField(name: string): FieldDef { - return { name, type: "json" }; -} - -function select(name: string, values: string[], required = false): FieldDef { - return { name, type: "select", required, values, maxSelect: 1 }; -} - -function rel(name: string, collectionId: string, required = false): FieldDef { - return { - name, - type: "relation", - required, - collectionId, - maxSelect: 1, - cascadeDelete: false, - }; -} - async function main() { console.log("Connecting to PB at", PB_ENDPOINT); const token = await getSuperadminToken(); console.log("Authenticated as superadmin\n"); const ids: Record = {}; - - // ── Pass 1: Independent collections ── - console.log("--- Pass 1: Independent collections ---"); - - ids.fams = await createCollection(token, { - name: "fams", - type: "base", - listRule: null, - viewRule: null, - createRule: null, - updateRule: null, - deleteRule: null, - fields: [ - text("name", true), - uniqueText("slug"), - text("inviteCode"), - text("stripeCustomerId"), - jsonField("featureFlags"), - jsonField("seasons"), - ], - }); - - // ── Pass 2: Collections that reference fams ── - console.log("\n--- Pass 2: fam-scoped collections ---"); - - ids.settings = await createCollection(token, { - name: "settings", - type: "base", - listRule: "", - viewRule: "", - createRule: null, - updateRule: null, - deleteRule: null, - fields: [rel("famId", ids.fams!, true), text("webhookUrl")], - }); - - ids.members = await createCollection(token, { - name: "members", - type: "base", - listRule: "", - viewRule: "", - createRule: null, - updateRule: null, - deleteRule: null, - fields: [ - rel("famId", ids.fams!, true), - text("name", true), - text("color"), - text("deviceToken"), - text("deviceTokenHint"), - ], - }); - - ids.chore_templates = await createCollection(token, { - name: "chore_templates", - type: "base", - listRule: "", - viewRule: "", - createRule: null, - updateRule: null, - deleteRule: null, - fields: [ - rel("famId", ids.fams!, true), - text("name", true), - text("description"), - select("defaultFrequency", ["daily", "weekly"], true), - select("defaultType", ["points", "money"], true), - number("defaultValue", true), - ], - }); - - ids.fam_admins = await createCollection(token, { - name: "fam_admins", - type: "base", - listRule: "", - viewRule: "", - createRule: null, - updateRule: null, - deleteRule: null, - fields: [ - rel("famId", ids.fams!, true), - text("userId", true), - text("email", true), - text("name"), - text("color"), - ], - }); - - ids.bonus_configs = await createCollection(token, { - name: "bonus_configs", - type: "base", - listRule: "", - viewRule: "", - createRule: null, - updateRule: null, - deleteRule: null, - fields: [ - rel("famId", ids.fams!, 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", ids.members!), - select("period", ["schedule", "daily", "weekly", "monthly"]), - select("status", ["active", "completed"], true), - ], - }); - - ids.bonus_templates = await createCollection(token, { - name: "bonus_templates", - type: "base", - listRule: "", - viewRule: "", - createRule: null, - updateRule: null, - deleteRule: null, - fields: [ - rel("famId", ids.fams!, 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 3: Collections with member/template deps ── - console.log("\n--- Pass 3: Nested collections ---"); - - ids.weekly_history = await createCollection(token, { - name: "weekly_history", - type: "base", - listRule: "", - viewRule: "", - createRule: null, - updateRule: null, - deleteRule: null, - fields: [ - rel("famId", ids.fams!, true), - rel("memberId", ids.members!, true), - date("weekStart"), - number("pointsEarned"), - number("moneyEarned"), - number("choresCompleted"), - number("bonusEarned"), - ], - }); - - ids.rewards = await createCollection(token, { - name: "rewards", - type: "base", - listRule: "", - viewRule: "", - createRule: null, - updateRule: null, - deleteRule: null, - fields: [ - rel("famId", ids.fams!, true), - rel("memberId", ids.members!, true), - rel("bonusConfigId", ids.bonus_configs!), - 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"), - ], - }); - - ids.assigned_chores = await createCollection(token, { - name: "assigned_chores", - type: "base", - listRule: "", - viewRule: "", - createRule: null, - updateRule: null, - deleteRule: null, - fields: [ - rel("famId", ids.fams!, true), - rel("memberId", ids.members!, true), - rel("templateId", ids.chore_templates!, true), - select("frequency", ["daily", "weekly"], true), - select("type", ["points", "money"], true), - number("value", true), - text("customName"), - jsonField("seasonIds"), - ], - }); - - ids.seasons = await createCollection(token, { - name: "seasons", - type: "base", - listRule: "", - viewRule: "", - createRule: null, - updateRule: null, - deleteRule: null, - fields: [ - rel("famId", ids.fams!, true), - text("name", true), - text("color"), - bool("active"), - date("autoDisable"), - date("autoStart"), - ], - }); - - ids.completions = await createCollection(token, { - name: "completions", - type: "base", - listRule: "", - viewRule: "", - createRule: null, - updateRule: null, - deleteRule: null, - fields: [ - rel("famId", ids.fams!, true), - rel("memberId", ids.members!, true), - rel("assignedChoreId", ids.assigned_chores!, true), - 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"), - ], - }); + for (const entry of SCHEMA_PLAN) { + const id = await createCollection(token, entry.build(ids)); + if (id) ids[entry.name] = id; + } console.log("\n✅ All collections created successfully"); console.log("Collection IDs:", ids); diff --git a/proxy/src/index.ts b/proxy/src/index.ts index 8ec0a59..0137e77 100644 --- a/proxy/src/index.ts +++ b/proxy/src/index.ts @@ -3,7 +3,7 @@ import { serve } from "@hono/node-server"; import { Hono } from "hono"; import { pb } from "./pb.ts"; import { migrate } from "./migrate.ts"; -import { PROXY_PORT } from "../../config.ts"; +import { PROXY_PORT } from "@shared/config.ts"; import { weekStart as tzWeekStart, addDaysStr, @@ -12,7 +12,7 @@ import { resolveTz, nextPaydayAfter as nextPaydayAfterTz, periodWindow, -} from "../../timezone.ts"; + } from "@shared/timezone.ts"; const app = new Hono(); diff --git a/proxy/src/migrate.ts b/proxy/src/migrate.ts index bccda2e..2913d2e 100644 --- a/proxy/src/migrate.ts +++ b/proxy/src/migrate.ts @@ -1,3 +1,4 @@ +import { SCHEMA_PLAN } from "@shared/pb/schema.ts"; import { PB_ENDPOINT, PB_EMAIL, PB_PASSWORD } from "./env.ts"; let token: string | null = null; @@ -63,53 +64,9 @@ async function updateCollection(id: string, col: any): Promise { 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. +// Idempotent — skips collections that already exist. Schema is shared with +// seed.ts via shared/pb/schema.ts. async function ensureSchema(): Promise { if (await getCollection("fams")) { console.log("[migrate] Base schema already present — skipping bootstrap."); @@ -117,163 +74,15 @@ async function ensureSchema(): Promise { } 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"), - ]), - ))!; + const ids: Record = {}; + for (const entry of SCHEMA_PLAN) { + const createdId = await createCollection(entry.build(ids)); + if (createdId) ids[entry.name] = createdId; + } + // 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"), - ]), - ); + const famsId = ids.fams; + if (famsId) await updateCollection(famsId, { viewRule: null, listRule: null }); console.log("[migrate] Base schema bootstrapped."); } diff --git a/proxy/tsconfig.json b/proxy/tsconfig.json index c2a36bf..cbc9aff 100644 --- a/proxy/tsconfig.json +++ b/proxy/tsconfig.json @@ -3,16 +3,16 @@ "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", - "outDir": "./dist", - "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true + "allowImportingTsExtensions": true, + "noEmit": true, + "paths": { + "@shared/*": ["../shared/*"] + } }, - "include": ["src/**/*"] + "include": ["src/**/*", "../shared/**/*"] } diff --git a/timezone.ts b/timezone.ts deleted file mode 100644 index d8084d8..0000000 --- a/timezone.ts +++ /dev/null @@ -1,217 +0,0 @@ -export const AUTO_TZ = "auto"; -export const DEFAULT_TZ = "UTC"; - -export function resolveTz(tz?: string | null): string { - if (tz && tz !== AUTO_TZ) return tz; - try { - return Intl.DateTimeFormat().resolvedOptions().timeZone || DEFAULT_TZ; - } catch { - return DEFAULT_TZ; - } -} - -export function dateStrInTz(date: Date, tz: string): string { - const parts = new Intl.DateTimeFormat("en-US", { - timeZone: tz, - year: "numeric", - month: "2-digit", - day: "2-digit", - }).formatToParts(date); - const get = (type: string) => - parts.find((p) => p.type === type)?.value ?? "00"; - return `${get("year")}-${get("month")}-${get("day")}`; -} - -export function weekdayInTz(date: Date, tz: string): number { - const parts = new Intl.DateTimeFormat("en-US", { - timeZone: tz, - weekday: "short", - }).formatToParts(date); - const wd = parts.find((p) => p.type === "weekday")?.value ?? ""; - const map: Record = { - Sun: 0, - Mon: 1, - Tue: 2, - Wed: 3, - Thu: 4, - Fri: 5, - Sat: 6, - }; - return map[wd] ?? date.getDay(); -} - -export function todayInTz(tz: string): string { - return dateStrInTz(new Date(), tz); -} - -export function addDaysStr(dateStr: string, days: number): string { - const [y, m, d] = dateStr.split("-").map(Number); - const dt = new Date(Date.UTC(y, m - 1, d + days)); - return dt.toISOString().slice(0, 10); -} - -export function weekStart(payday: number, tz: string): string { - const today = todayInTz(tz); - const wd = weekdayInTz(new Date(), tz); - const back = (((wd - payday) % 7) + 7) % 7; - return addDaysStr(today, -back); -} - -// The first configured-payday day strictly after dateStr (used to gate -// payday-settled bonus rewards). Weekly: periodEnd (weekStart+6) → next payday. -export function nextPaydayAfter( - dateStr: string, - payday: number, - tz: string, -): string { - let d = addDaysStr(dateStr, 1); - while (weekdayInTz(new Date(d + "T12:00:00Z"), tz) !== payday) { - d = addDaysStr(d, 1); - } - return d; -} - -function monthStartStr(): string { - const d = new Date(); - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-01`; -} - -function monthEndStr(month?: string): string { - if (!month) { - const d = new Date(); - month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; - } - const [y, m] = month.split("-").map(Number); - const lastDay = new Date(y, m, 0).getDate(); - return `${month}-${String(lastDay).padStart(2, "0")}`; -} - -export function periodStart( - period: string, - payday: number, - tz: string, -): string { - if (period === "daily") return todayInTz(tz); - if (period === "weekly") return weekStart(payday, tz); - if (period === "monthly") return monthStartStr(); - return weekStart(payday, tz); -} - -export function periodEnd(period: string, start: string): string { - if (period === "daily") return start; - if (period === "weekly") return addDaysStr(start, 6); - if (period === "monthly") return monthEndStr(start.slice(0, 7)); - return start; -} - -// 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 [h, min] = time.split(":").map(Number); - let epoch = Date.UTC(y, m - 1, d, h, min); - for (let i = 0; i < 4; i++) { - const parts = new Intl.DateTimeFormat("en-US", { - timeZone: tz, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - hour12: false, - }).formatToParts(new Date(epoch)); - const get = (type: string) => - parts.find((p) => p.type === type)?.value ?? "00"; - const gotDate = `${get("year")}-${get("month")}-${get("day")}`; - let gotHour = parseInt(get("hour"), 10); - if (gotHour === 24) gotHour = 0; - const gotEpoch = Date.UTC( - parseInt(get("year"), 10), - parseInt(get("month"), 10) - 1, - parseInt(get("day"), 10), - gotHour, - parseInt(get("minute"), 10), - ); - if (gotEpoch === epoch && gotDate === dateStr) break; - epoch = Date.UTC(y, m - 1, d, h, min) + (epoch - gotEpoch); - } - return epoch; -} - -export const COMMON_TIMEZONES: string[] = [ - "Europe/London", - "Europe/Paris", - "Europe/Berlin", - "Europe/Madrid", - "Europe/Rome", - "Europe/Amsterdam", - "Europe/Lisbon", - "Europe/Dublin", - "Europe/Warsaw", - "Europe/Stockholm", - "Europe/Athens", - "Europe/Istanbul", - "Europe/Moscow", - "America/New_York", - "America/Chicago", - "America/Denver", - "America/Los_Angeles", - "America/Toronto", - "America/Vancouver", - "America/Sao_Paulo", - "America/Mexico_City", - "Asia/Tokyo", - "Asia/Shanghai", - "Asia/Hong_Kong", - "Asia/Singapore", - "Asia/Seoul", - "Asia/Kolkata", - "Asia/Dubai", - "Asia/Jerusalem", - "Asia/Bangkok", - "Asia/Manila", - "Australia/Sydney", - "Australia/Melbourne", - "Australia/Brisbane", - "Australia/Perth", - "Australia/Adelaide", - "Pacific/Auckland", - "Africa/Johannesburg", - "Africa/Cairo", - "Africa/Lagos", - "Africa/Nairobi", - "UTC", -];