diff --git a/shared/config.ts b/shared/config.ts new file mode 100644 index 0000000..8070cf8 --- /dev/null +++ b/shared/config.ts @@ -0,0 +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 PROXY_PORT = "3456"; +export const PB_PORT = "8090"; diff --git a/shared/pb/schema.ts b/shared/pb/schema.ts new file mode 100644 index 0000000..b8e1aca --- /dev/null +++ b/shared/pb/schema.ts @@ -0,0 +1,279 @@ +// Single source of truth for the PocketBase schema + field builders. +// Consumed by BOTH proxy/src/migrate.ts (idempotent bootstrap) and +// proxy/scripts/seed.ts (fresh-store seed) so the schema isn't duplicated. +// +// Relations reference collections by name; the `ids` map maps collection +// name -> runtime id (filled as each collection is created). + +export interface FieldDef { + name: string; + type: string; + required?: boolean; + unique?: boolean; + max?: number; + min?: number; + values?: string[]; + maxSelect?: number; + collectionId?: string; + cascadeDelete?: boolean; +} + +export interface CollectionDef { + name: string; + type: string; + fields: FieldDef[]; + listRule?: string | null; + viewRule?: string | null; + createRule?: string | null; + updateRule?: string | null; + deleteRule?: string | null; +} + +// ── Field builders ── +export function text(name: string, required = false): FieldDef { + return { name, type: "text", required }; +} + +export function uniqueText(name: string): FieldDef { + return { name, type: "text", required: true, unique: true }; +} + +export function number(name: string, required = false): FieldDef { + return { name, type: "number", required }; +} + +export function bool(name: string): FieldDef { + return { name, type: "bool" }; +} + +export function date(name: string): FieldDef { + return { name, type: "date" }; +} + +export function jsonField(name: string): FieldDef { + return { name, type: "json" }; +} + +export function select(name: string, values: string[], required = false): FieldDef { + return { name, type: "select", required, values, maxSelect: 1 }; +} + +export function rel(name: string, collectionId: string, required = false): FieldDef { + return { + name, + type: "relation", + required, + collectionId, + maxSelect: 1, + cascadeDelete: false, + }; +} + +// ── Collection builder ── +export function col( + name: string, + fields: FieldDef[], + rules: { listRule?: string | null; viewRule?: string | null } = {}, +): (ids: Record) => CollectionDef { + return (ids) => ({ + name, + type: "base", + listRule: rules.listRule ?? "", + viewRule: rules.viewRule ?? "", + createRule: null, + updateRule: null, + deleteRule: null, + fields, + }); +} + +export type CollectionPlanEntry = { + name: string; + build: (ids: Record) => CollectionDef; +}; + +// Ordered so every relation's target already exists (and its id is in `ids`) +// when a collection is built. `fams` is superadmin-only (listRule/viewRule null). +export const SCHEMA_PLAN: CollectionPlanEntry[] = [ + { + name: "fams", + build: (ids) => + col( + "fams", + [ + text("name", true), + uniqueText("slug"), + text("inviteCode"), + text("stripeCustomerId"), + jsonField("featureFlags"), + jsonField("seasons"), + ], + { listRule: null, viewRule: null }, + )(ids), + }, + { + name: "bonus_templates", + build: (ids) => + col("bonus_templates", [ + 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"]), + ])(ids), + }, + { + name: "settings", + build: (ids) => + col("settings", [rel("famId", ids.fams, true), text("webhookUrl")])(ids), + }, + { + name: "members", + build: (ids) => + col("members", [ + rel("famId", ids.fams, true), + text("name", true), + text("color"), + text("deviceToken"), + text("deviceTokenHint"), + ])(ids), + }, + { + name: "chore_templates", + build: (ids) => + col("chore_templates", [ + rel("famId", ids.fams, true), + text("name", true), + text("description"), + select("defaultFrequency", ["daily", "weekly"], true), + select("defaultType", ["points", "money"], true), + number("defaultValue", true), + ])(ids), + }, + { + name: "fam_admins", + build: (ids) => + col("fam_admins", [ + rel("famId", ids.fams, true), + text("userId", true), + text("email", true), + text("name"), + text("color"), + ])(ids), + }, + { + name: "bonus_configs", + build: (ids) => + col("bonus_configs", [ + 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), + }, + { + name: "weekly_history", + build: (ids) => + col("weekly_history", [ + rel("famId", ids.fams, true), + rel("memberId", ids.members, true), + date("weekStart"), + number("pointsEarned"), + number("moneyEarned"), + number("choresCompleted"), + number("bonusEarned"), + ])(ids), + }, + { + name: "seasons", + build: (ids) => + col("seasons", [ + rel("famId", ids.fams, true), + text("name", true), + text("color"), + bool("active"), + date("autoDisable"), + date("autoStart"), + ])(ids), + }, + { + name: "messages", + build: (ids) => + col("messages", [ + rel("famId", ids.fams, true), + select("authorType", ["admin", "member"], true), + text("authorId", true), + text("authorName", true), + text("authorColor"), + text("content", true), + ])(ids), + }, + { + name: "chat_typing", + build: (ids) => + col("chat_typing", [ + rel("famId", ids.fams, true), + text("actorId", true), + select("actorType", ["admin", "member"], true), + text("authorName", true), + text("authorColor"), + bool("typing"), + ])(ids), + }, + { + name: "rewards", + build: (ids) => + col("rewards", [ + 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), + }, + { + name: "assigned_chores", + build: (ids) => + col("assigned_chores", [ + 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), + }, + { + name: "completions", + build: (ids) => + col("completions", [ + rel("famId", ids.fams, true), + rel("memberId", ids.members, true), + rel("assignedChoreId", ids.assigned_chores, true), + date("date"), + date("completedAt"), + ])(ids), + }, +]; diff --git a/shared/timezone.ts b/shared/timezone.ts new file mode 100644 index 0000000..d8084d8 --- /dev/null +++ b/shared/timezone.ts @@ -0,0 +1,217 @@ +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", +];