add shared resources directory
This commit is contained in:
@@ -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";
|
||||
@@ -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<string, string>) => 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<string, string>) => 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),
|
||||
},
|
||||
];
|
||||
@@ -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<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",
|
||||
];
|
||||
Reference in New Issue
Block a user