update compose to be coolifyable

This commit is contained in:
JCEEE
2026-08-07 11:37:46 +01:00
parent 08a20d29e5
commit 03dd8db1a2
13 changed files with 59 additions and 774 deletions
+14
View File
@@ -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.
+6
View File
@@ -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
-5
View File
@@ -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";
+5 -5
View File
@@ -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
@@ -17,7 +17,7 @@
periodStart,
periodEnd,
isCompleteForPeriod
} from '../../../../../timezone.ts';
} from '@shared/timezone';
let { data } = $props();
@@ -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();
+4
View File
@@ -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: {
+1 -1
View File
@@ -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"
},
+8 -334
View File
@@ -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<string> {
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<string, string> = {};
// ── 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);
+2 -2
View File
@@ -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();
+11 -202
View File
@@ -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<void> {
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<void> {
if (await getCollection("fams")) {
console.log("[migrate] Base schema already present — skipping bootstrap.");
@@ -117,163 +74,15 @@ async function ensureSchema(): Promise<void> {
}
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<string, string> = {};
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.");
}
+6 -6
View File
@@ -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/**/*"]
}
-217
View File
@@ -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<string, number> = {
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",
];