@flaredev/cli 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/index.js +851 -405
  2. package/package.json +2 -2
  3. package/templates/app/app/dashboard/actions.ts +38 -5
  4. package/templates/app/app/dashboard/costs/page.tsx +93 -0
  5. package/templates/app/app/dashboard/page.tsx +4 -7
  6. package/templates/app/app/globals.css +0 -6
  7. package/templates/app/app/layout.tsx +37 -28
  8. package/templates/app/app/page.tsx +10 -0
  9. package/templates/app/components/dashboard/cost-estimator.tsx +77 -0
  10. package/templates/app/components/dashboard/fields/field-widget.tsx +237 -186
  11. package/templates/app/components/dashboard/fields/file-field.tsx +5 -1
  12. package/templates/app/components/dashboard/fields/relation-field.tsx +25 -3
  13. package/templates/app/components/dashboard/import-dialog.tsx +2 -2
  14. package/templates/app/components/dashboard/record-actions.tsx +9 -9
  15. package/templates/app/components/dashboard/record-detail.tsx +6 -3
  16. package/templates/app/components/dashboard/resource-form-page.tsx +64 -61
  17. package/templates/app/components/dashboard/resource-form-sheet.tsx +86 -0
  18. package/templates/app/components/dashboard/resource-form.tsx +261 -165
  19. package/templates/app/components/dashboard/resource-icon.tsx +87 -85
  20. package/templates/app/components/dashboard/resource-stats.tsx +10 -9
  21. package/templates/app/components/dashboard/resource-table-toolbar.tsx +2 -2
  22. package/templates/app/components/dashboard/resource-table.tsx +12 -9
  23. package/templates/app/components/dashboard/row-actions.tsx +5 -5
  24. package/templates/app/lib/costs.ts +138 -0
  25. package/templates/app/lib/dashboard-nav.ts +5 -1
  26. package/templates/app/lib/dashboard.ts +66 -61
  27. package/templates/app/lib/number.ts +33 -0
  28. package/templates/app/lib/site.ts +56 -56
  29. package/templates/app/lib/suggest.ts +35 -0
  30. package/templates/app/lib/usage.ts +32 -0
  31. package/templates/locks/package-lock.json +9 -9
  32. package/templates/locks/pnpm-lock.yaml +12 -12
  33. package/templates/locks/yarn.lock +3 -3
  34. package/templates/next/.env.example +21 -0
  35. package/templates/next/README.md +16 -0
  36. package/templates/next/app/api/health/route.ts +7 -0
  37. package/templates/next/app/dashboard/account/password/page.tsx +19 -0
  38. package/templates/next/app/dashboard/account/security/page.tsx +31 -0
  39. package/templates/next/app/dashboard/actions.ts +277 -0
  40. package/templates/next/app/dashboard/layout.tsx +64 -0
  41. package/templates/next/app/dashboard/page.tsx +127 -0
  42. package/templates/next/components/dashboard/resource-chart.tsx +86 -0
  43. package/templates/next/lib/analytics.ts +71 -0
  44. package/templates/next/lib/audit.ts +80 -0
  45. package/templates/next/lib/auth-emails.ts +92 -0
  46. package/templates/next/lib/auth.ts +156 -0
  47. package/templates/next/lib/dashboard.ts +161 -0
  48. package/templates/next/lib/db.ts +24 -0
  49. package/templates/next/lib/mail.ts +35 -0
  50. package/templates/next/lib/realtime.ts +23 -0
  51. package/templates/next/lib/redis.ts +17 -0
  52. package/templates/next/lib/storage.ts +57 -0
  53. package/templates/next/lib/theme.ts +83 -0
  54. package/templates/next/lib/views.ts +23 -0
  55. package/templates/next/next.config.ts +13 -0
  56. package/templates/next/prisma/schema/base.prisma +162 -0
  57. package/templates/next/prisma.config.ts +15 -0
  58. package/templates/next/tsconfig.json +24 -0
  59. package/templates/app/components/dashboard/resource-form-dialog.tsx +0 -80
@@ -1,5 +1,5 @@
1
1
  import { storedFields, optionLabel, type Resource } from "@flaredev/core";
2
- import { recentCounts, recordCount, trend, valueCounts } from "@/lib/dashboard";
2
+ import { resourceStats, trend } from "@/lib/dashboard";
3
3
  import { resourceIcon } from "./resource-icon";
4
4
  import { StatCards, type Stat } from "./stat-card";
5
5
 
@@ -11,7 +11,10 @@ import { StatCards, type Stat } from "./stat-card";
11
11
  * in practice is the status of the thing — a deal's stage, an invoice's state.
12
12
  */
13
13
  export async function ResourceStats({ resource }: { resource: Resource }) {
14
- const [total, recent] = await Promise.all([recordCount(resource.name), recentCounts(resource.name)]);
14
+ // The status split is asked for up front so the totals, the trend and the split all
15
+ // come from the one grouped query rather than three.
16
+ const statusField = storedFields(resource).find(([, def]) => def.kind === "enum" && def.filterable !== false);
17
+ const { total, current, previous, values } = await resourceStats(resource.name, statusField?.[0]);
15
18
 
16
19
  const stats: Stat[] = [
17
20
  {
@@ -21,20 +24,18 @@ export async function ResourceStats({ resource }: { resource: Resource }) {
21
24
  },
22
25
  {
23
26
  label: "New this week",
24
- value: recent.current,
25
- change: trend(recent.current, recent.previous),
26
- hint: `${recent.previous.toLocaleString()} the week before`,
27
+ value: current,
28
+ change: trend(current, previous),
29
+ hint: `${previous.toLocaleString()} the week before`,
27
30
  },
28
31
  ];
29
32
 
30
- const statusField = storedFields(resource).find(([, def]) => def.kind === "enum" && def.filterable !== false);
31
33
  if (statusField && total > 0) {
32
- const [key, def] = statusField;
34
+ const [, def] = statusField;
33
35
  if (def.kind === "enum") {
34
- const counts = await valueCounts(resource.name, key);
35
36
  // Two options at most: a strip of eight cards is a wall, not a summary.
36
37
  for (const option of def.options.slice(0, 2)) {
37
- const value = counts[option] ?? 0;
38
+ const value = values[option] ?? 0;
38
39
  stats.push({
39
40
  label: optionLabel(def, option),
40
41
  value,
@@ -3,7 +3,7 @@
3
3
  import { usePathname, useRouter, useSearchParams } from "next/navigation";
4
4
  import { useState, type FormEvent } from "react";
5
5
  import { SearchIcon, XIcon } from "lucide-react";
6
- import { optionLabel, storedFields, type Resource } from "@flaredev/core";
6
+ import { optionLabel, storedFields, type ClientResource } from "@flaredev/core";
7
7
  import { Button } from "@/components/ui/button";
8
8
  import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
9
9
  import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -12,7 +12,7 @@ import { hrefWith } from "./query";
12
12
  const ALL = "__all__";
13
13
 
14
14
  /** Search box and enum/boolean filters for ResourceTable; all state goes into the URL. */
15
- export function ResourceTableToolbar({ resource }: { resource: Resource }) {
15
+ export function ResourceTableToolbar({ resource }: { resource: ClientResource }) {
16
16
  const router = useRouter();
17
17
  const pathname = usePathname();
18
18
  const params = new URLSearchParams(useSearchParams().toString());
@@ -1,6 +1,6 @@
1
1
  import Link from "next/link";
2
2
  import { ArrowDownIcon, ArrowUpIcon, ChevronLeftIcon, ChevronRightIcon, ChevronsUpDownIcon, PlusIcon } from "lucide-react";
3
- import { formatValue, optionLabel, statusTone, storedFields, type Resource, type StoredField } from "@flaredev/core";
3
+ import { clientResource, formatValue, optionLabel, statusTone, storedFields, type Resource, type StoredField } from "@flaredev/core";
4
4
  import { isSortable } from "@flaredev/core/server";
5
5
  import { Alert, AlertDescription } from "@/components/ui/alert";
6
6
  import { Badge } from "@/components/ui/badge";
@@ -26,7 +26,7 @@ import type { RelationMeta } from "./fields/field-widget";
26
26
  import { ImportDialog } from "./import-dialog";
27
27
  import { LocalTime } from "./local-time";
28
28
  import { hrefWith, toSearchParams, type SearchParams } from "./query";
29
- import { NewRecordButton } from "./resource-form-dialog";
29
+ import { NewRecordButton } from "./resource-form-sheet";
30
30
  import { ResourceTableToolbar } from "./resource-table-toolbar";
31
31
  import { SaveViewButton } from "./save-view-button";
32
32
  import { RowActions } from "./row-actions";
@@ -44,6 +44,9 @@ const NUMERIC = new Set(["int", "float"]);
44
44
  */
45
45
  export async function ResourceTable({ resource, searchParams }: { resource: Resource; searchParams: SearchParams }) {
46
46
  await requireAccess(resource, "read");
47
+ // Hooks and computed values are functions, and a function can't be sent to the browser.
48
+ // This is the same resource without them, for the client components below.
49
+ const forClient = clientResource(resource);
47
50
  const basePath = resourcePath(resource);
48
51
  const permissions = await adminPermissions(resource.name);
49
52
  const params = toSearchParams(searchParams);
@@ -87,7 +90,7 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
87
90
  relations[key] = { name: target.name, label: target.label, pluralLabel: target.pluralLabel, slug: target.slug, titleField: target.titleField };
88
91
  }
89
92
 
90
- const modalForms = site.dashboard.forms === "modal";
93
+ const overlayForms = site.dashboard.forms === "sheet";
91
94
  const rowIds = rows.map((row) => String(row.id));
92
95
  const exportColumns = [{ key: "id", label: "Id" }, ...fields.filter(([, def]) => def.kind !== "file").map(([key, def]) => ({ key, label: def.label }))];
93
96
 
@@ -102,15 +105,15 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
102
105
  <TableSelection ids={rowIds}>
103
106
  <div className="flex flex-col gap-4">
104
107
  <div className="flex flex-wrap items-center justify-between gap-2">
105
- <ResourceTableToolbar resource={resource} />
108
+ <ResourceTableToolbar resource={forClient} />
106
109
  <div className="flex flex-wrap items-center gap-2">
107
110
  <SaveViewButton resourceName={resource.name} label={resource.pluralLabel} />
108
111
  <ColumnMenu columns={allColumns.map(({ key, label }) => ({ key, label }))} visible={columns.map((column) => column.key)} />
109
112
  <ExportButton resourceName={resource.name} pluralLabel={resource.pluralLabel} />
110
- {permissions.create && <ImportDialog resource={resource} />}
113
+ {permissions.create && <ImportDialog resource={forClient} />}
111
114
  {permissions.create &&
112
- (modalForms ? (
113
- <NewRecordButton resource={resource} relations={relations} listHref={basePath} />
115
+ (overlayForms ? (
116
+ <NewRecordButton resource={forClient} relations={relations} listHref={basePath} />
114
117
  ) : (
115
118
  <Button asChild>
116
119
  <Link href={resourcePath(resource, "new")}>
@@ -281,9 +284,9 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
281
284
  })}
282
285
  <TableCell className="text-right">
283
286
  <RowActions
284
- resource={resource}
287
+ resource={forClient}
285
288
  id={id}
286
- record={modalForms ? row : undefined}
289
+ record={overlayForms ? row : undefined}
287
290
  relations={relations}
288
291
  listHref={basePath}
289
292
  editHref={resourcePath(resource, id, "edit")}
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
5
5
  import { useState, useTransition } from "react";
6
6
  import { ExternalLinkIcon, MoreHorizontalIcon, PencilIcon, Trash2Icon } from "lucide-react";
7
7
  import { toast } from "sonner";
8
- import type { Resource } from "@flaredev/core";
8
+ import type { ClientResource } from "@flaredev/core";
9
9
  import { deleteRecordAction } from "@/app/dashboard/actions";
10
10
  import {
11
11
  AlertDialog,
@@ -26,11 +26,11 @@ import {
26
26
  DropdownMenuTrigger,
27
27
  } from "@/components/ui/dropdown-menu";
28
28
  import { Spinner } from "@/components/ui/spinner";
29
- import { ResourceFormDialog, type FormRelations } from "./resource-form-dialog";
29
+ import { ResourceFormSheet, type FormRelations } from "./resource-form-sheet";
30
30
 
31
31
  /**
32
32
  * Edit and delete for one row. Edit opens a dialog when the row's record came with it
33
- * (site.dashboard.forms is "modal"), and otherwise goes to the form page.
33
+ * (site.dashboard.forms is "sheet"), and otherwise goes to the form page.
34
34
  */
35
35
  export function RowActions({
36
36
  resource,
@@ -43,7 +43,7 @@ export function RowActions({
43
43
  canUpdate = true,
44
44
  canDelete = true,
45
45
  }: {
46
- resource: Resource;
46
+ resource: ClientResource;
47
47
  id: string;
48
48
  /** The record itself, when the form opens in a dialog. */
49
49
  record?: Record<string, unknown>;
@@ -124,7 +124,7 @@ export function RowActions({
124
124
  </DropdownMenu>
125
125
 
126
126
  {record && canUpdate && (
127
- <ResourceFormDialog
127
+ <ResourceFormSheet
128
128
  resource={resource}
129
129
  relations={relations}
130
130
  listHref={listHref}
@@ -0,0 +1,138 @@
1
+ /**
2
+ * What Cloudflare charges, in one place.
3
+ *
4
+ * Checked against Cloudflare's pricing pages on 24 September 2026. They change; when
5
+ * they do, change them here rather than in the page that displays them. The docs cover
6
+ * the reasoning behind these numbers:
7
+ * https://flare-docs.codetotech.com/guides/costs/
8
+ *
9
+ * Nothing here is a bill. The app can see how much it stores; it cannot see how many
10
+ * requests it served or how many rows it read — those live in Cloudflare's analytics,
11
+ * behind an API token this app deliberately doesn't hold. So this is a model, and the
12
+ * page that shows it says so.
13
+ */
14
+
15
+ /** The Workers Paid subscription, which is the whole bill for most apps. */
16
+ export const SUBSCRIPTION_USD = 5;
17
+
18
+ /** Free plan allowances. Daily where Cloudflare counts them daily. */
19
+ export const FREE = {
20
+ requestsPerDay: 100_000,
21
+ /** CPU milliseconds per invocation — the one that bites a server-rendered app. */
22
+ cpuMsPerRequest: 10,
23
+ d1RowsReadPerDay: 5_000_000,
24
+ d1RowsWrittenPerDay: 100_000,
25
+ d1StorageGb: 5,
26
+ r2StorageGb: 10,
27
+ kvReadsPerDay: 100_000,
28
+ /** A thousand a day, and every cache miss is a write. */
29
+ kvWritesPerDay: 1_000,
30
+ } as const;
31
+
32
+ /** Paid plan: what's included each month, then the rate past it. */
33
+ export const PAID = {
34
+ requests: { included: 10_000_000, perMillionUsd: 0.3 },
35
+ cpuMs: { included: 30_000_000, perMillionUsd: 0.02 },
36
+ d1RowsRead: { included: 25_000_000_000, perMillionUsd: 0.001 },
37
+ d1RowsWritten: { included: 50_000_000, perMillionUsd: 1 },
38
+ d1StorageGb: { included: 5, perGbMonthUsd: 0.75 },
39
+ r2StorageGb: { included: 10, perGbMonthUsd: 0.015 },
40
+ kvReads: { included: 10_000_000, perMillionUsd: 0.5 },
41
+ kvWrites: { included: 1_000_000, perMillionUsd: 5 },
42
+ } as const;
43
+
44
+ /**
45
+ * What one page view costs, measured on the example shop.
46
+ *
47
+ * A dashboard page runs the stats query, the chart, the table's page and its capped
48
+ * count, plus one lookup per relation shown — four to six queries, reading around a
49
+ * hundred rows between them once the indexes are doing their job. A page on the public
50
+ * side is lighter. These are the assumptions the estimate below rests on, and they are
51
+ * written down so you can argue with them.
52
+ */
53
+ export const PER_VIEW = {
54
+ d1RowsRead: 100,
55
+ d1RowsWritten: 0.2,
56
+ kvReads: 4,
57
+ /** Cache misses are rarer than hits; most views read a warm entry. */
58
+ kvWrites: 0.3,
59
+ cpuMs: 25,
60
+ } as const;
61
+
62
+ export interface Estimate {
63
+ monthlyViews: number;
64
+ requests: number;
65
+ d1RowsRead: number;
66
+ d1RowsWritten: number;
67
+ kvReads: number;
68
+ kvWrites: number;
69
+ cpuMs: number;
70
+ /** Dollars on top of the subscription. */
71
+ overageUsd: number;
72
+ totalUsd: number;
73
+ /** Which free-plan allowance this traffic would break first, if any. */
74
+ freePlanVerdict: string;
75
+ }
76
+
77
+ const over = (used: number, included: number, perMillionUsd: number) =>
78
+ used <= included ? 0 : ((used - included) / 1_000_000) * perMillionUsd;
79
+
80
+ /** What a month at this traffic would cost, and whether the free plan could carry it. */
81
+ export function estimate(monthlyViews: number, storedGb: { d1: number; r2: number }): Estimate {
82
+ const requests = monthlyViews;
83
+ const d1RowsRead = monthlyViews * PER_VIEW.d1RowsRead;
84
+ const d1RowsWritten = monthlyViews * PER_VIEW.d1RowsWritten;
85
+ const kvReads = monthlyViews * PER_VIEW.kvReads;
86
+ const kvWrites = monthlyViews * PER_VIEW.kvWrites;
87
+ const cpuMs = monthlyViews * PER_VIEW.cpuMs;
88
+
89
+ const overageUsd =
90
+ over(requests, PAID.requests.included, PAID.requests.perMillionUsd) +
91
+ over(cpuMs, PAID.cpuMs.included, PAID.cpuMs.perMillionUsd) +
92
+ over(d1RowsRead, PAID.d1RowsRead.included, PAID.d1RowsRead.perMillionUsd) +
93
+ over(d1RowsWritten, PAID.d1RowsWritten.included, PAID.d1RowsWritten.perMillionUsd) +
94
+ over(kvReads, PAID.kvReads.included, PAID.kvReads.perMillionUsd) +
95
+ over(kvWrites, PAID.kvWrites.included, PAID.kvWrites.perMillionUsd) +
96
+ Math.max(0, storedGb.d1 - PAID.d1StorageGb.included) * PAID.d1StorageGb.perGbMonthUsd +
97
+ Math.max(0, storedGb.r2 - PAID.r2StorageGb.included) * PAID.r2StorageGb.perGbMonthUsd;
98
+
99
+ const daily = monthlyViews / 30;
100
+ const broken: string[] = [];
101
+ if (daily > FREE.requestsPerDay) broken.push("requests");
102
+ if (daily * PER_VIEW.d1RowsRead > FREE.d1RowsReadPerDay) broken.push("D1 rows read");
103
+ if (daily * PER_VIEW.kvWrites > FREE.kvWritesPerDay) broken.push("KV writes");
104
+ if (storedGb.r2 > FREE.r2StorageGb) broken.push("R2 storage");
105
+ if (storedGb.d1 > FREE.d1StorageGb) broken.push("D1 storage");
106
+
107
+ return {
108
+ monthlyViews,
109
+ requests,
110
+ d1RowsRead,
111
+ d1RowsWritten,
112
+ kvReads,
113
+ kvWrites,
114
+ cpuMs,
115
+ overageUsd,
116
+ totalUsd: SUBSCRIPTION_USD + overageUsd,
117
+ freePlanVerdict: broken.length === 0
118
+ ? "Within the free allowances — but see the CPU note below."
119
+ : `Past the free plan on ${broken.join(", ")}.`,
120
+ };
121
+ }
122
+
123
+ /** "1.2M", "34k", "912" — counts that have to fit on a card. */
124
+ export function compact(value: number): string {
125
+ if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(value >= 10_000_000 ? 0 : 1)}M`;
126
+ if (value >= 1_000) return `${(value / 1_000).toFixed(value >= 10_000 ? 0 : 1)}k`;
127
+ return Math.round(value).toLocaleString();
128
+ }
129
+
130
+ /** "$5.00", "$12.34". */
131
+ export const usd = (value: number) => `$${value.toFixed(2)}`;
132
+
133
+ /** "2.4 GB", "812 MB". */
134
+ export function gb(value: number): string {
135
+ if (value >= 1) return `${value.toFixed(value >= 10 ? 0 : 2)} GB`;
136
+ const mb = value * 1024;
137
+ return mb >= 1 ? `${mb.toFixed(mb >= 10 ? 0 : 1)} MB` : `${Math.round(mb * 1024)} KB`;
138
+ }
@@ -12,4 +12,8 @@ export const generatedDashboardLinks: DashboardLink[] = [];
12
12
  // generated:end
13
13
 
14
14
  /** Links shown under "Platform" in the dashboard sidebar. */
15
- export const dashboardLinks: DashboardLink[] = [...generatedDashboardLinks, { label: "API reference", href: "/api/reference", icon: "book" }];
15
+ export const dashboardLinks: DashboardLink[] = [
16
+ { label: "Costs", href: "/dashboard/costs", icon: "wallet" },
17
+ ...generatedDashboardLinks,
18
+ { label: "API reference", href: "/api/reference", icon: "book" },
19
+ ];
@@ -1,7 +1,7 @@
1
1
  import { headers } from "next/headers";
2
2
  import { notFound, redirect } from "next/navigation";
3
3
  import { allowedActions, can, type Policy, type PolicyAction, type Resource } from "@flaredev/core";
4
- import { and, count, gte, lt } from "drizzle-orm";
4
+ import { count, sql } from "drizzle-orm";
5
5
  import type { SQLiteTable } from "drizzle-orm/sqlite-core";
6
6
  import { createResourceStore, type ResourceStore } from "@flaredev/core/server";
7
7
  import { getDb } from "@/db";
@@ -73,74 +73,79 @@ export function dashboardStore(name: string): ResourceStore {
73
73
  }
74
74
 
75
75
  /**
76
- * How many records a resource has. Cached in the data cache under the resource's tag,
77
- * so the dashboard does not count every table on every visit, and a write through the
78
- * admin or the API drops the count on its way out.
76
+ * Every number the stats above a resource's table need, in one query.
77
+ *
78
+ * The total, how many arrived in the last week and the week before, and how many hold
79
+ * each value of a status field — all of it from a single grouped scan, summed up here.
80
+ * It was four queries and three cache entries; on the free plan the cache entries were
81
+ * the expensive half, because Workers KV allows a thousand writes a day and every write
82
+ * to a record drops the entries tagged for it.
83
+ *
84
+ * Cached under the resource's tag, so a write through the admin or the API drops it on
85
+ * the way out and the next view recomputes it.
79
86
  */
80
- export function recordCount(name: string): Promise<number> {
81
- const counted = cached(
82
- async (resourceName: string) => {
83
- // Counted straight off the table, not through the store: a list stops counting at
84
- // 10,000 so that paging stays quick, and a stat wants the real number.
85
- const entry = (resourceTables as unknown as Record<string, { table: SQLiteTable } | undefined>)[resourceName];
86
- if (!entry) return 0;
87
- const [row] = await getDb().select({ total: count() }).from(entry.table);
88
- return row?.total ?? 0;
89
- },
90
- ["flare", "record-count"],
91
- { tags: [resourceTag(name)], revalidate: TTL.short },
92
- );
93
- return counted(name);
87
+ export interface ResourceStats {
88
+ total: number;
89
+ /** Created in the last `days` days, and in the `days` before that. */
90
+ current: number;
91
+ previous: number;
92
+ /** Rows per value of the grouped field, when one was asked for. */
93
+ values: Record<string, number>;
94
94
  }
95
95
 
96
- /**
97
- * How many records hold each value of a field, in one grouped query rather than one
98
- * count per option. Used by the stats above a resource's table.
99
- */
100
- export function valueCounts(name: string, field: string): Promise<Record<string, number>> {
101
- const counted = cached(
102
- async (resourceName: string, key: string) => {
96
+ export function resourceStats(name: string, field?: string, days = 7): Promise<ResourceStats> {
97
+ const read = cached(
98
+ async (resourceName: string, key: string | undefined, windowDays: number): Promise<ResourceStats> => {
103
99
  const entry = (resourceTables as unknown as Record<string, { table: SQLiteTable } | undefined>)[resourceName];
104
- const column = entry ? (entry.table as unknown as Record<string, unknown>)[key] : undefined;
105
- if (!entry || !column) return {};
106
- const rows = (await getDb()
107
- .select({ value: column as never, total: count() })
108
- .from(entry.table)
109
- .groupBy(column as never)) as { value: unknown; total: number }[];
110
- return Object.fromEntries(rows.map((row) => [String(row.value ?? ""), row.total]));
111
- },
112
- ["flare", "value-counts"],
113
- { tags: [resourceTag(name)], revalidate: TTL.short },
114
- );
115
- return counted(name, field);
116
- }
100
+ if (!entry) return { total: 0, current: 0, previous: 0, values: {} };
117
101
 
118
- /**
119
- * How many records a resource gained in the last `days` days, and in the `days` before
120
- * that, so a stat can show which way it's going. Cached like the totals.
121
- */
122
- export function recentCounts(name: string, days = 7): Promise<{ current: number; previous: number }> {
123
- const counts = cached(
124
- async (resourceName: string, windowDays: number) => {
125
- const entry = (resourceTables as unknown as Record<string, { table: SQLiteTable } | undefined>)[resourceName];
126
- const createdAt = entry ? (entry.table as unknown as Record<string, unknown>).createdAt : undefined;
127
- // A resource whose table has no createdAt (a custom one) simply has no trend.
128
- if (!entry || !createdAt) return { current: 0, previous: 0 };
102
+ const columns = entry.table as unknown as Record<string, unknown>;
103
+ const createdAt = columns.createdAt;
104
+ const grouped = key ? columns[key] : undefined;
105
+ // Milliseconds, not Dates: these go into a raw `sql` fragment, which doesn't pass
106
+ // through the column's mapper, and D1 refuses to bind an object.
129
107
  const window = windowDays * 86_400_000;
130
- const since = new Date(Date.now() - window);
131
- const before = new Date(Date.now() - window * 2);
132
- const column = createdAt as never;
133
- const [current] = await getDb().select({ total: count() }).from(entry.table).where(gte(column, since));
134
- const [previous] = await getDb()
135
- .select({ total: count() })
136
- .from(entry.table)
137
- .where(and(gte(column, before), lt(column, since)));
138
- return { current: current?.total ?? 0, previous: previous?.total ?? 0 };
108
+ const since = Date.now() - window;
109
+ const before = Date.now() - window * 2;
110
+
111
+ // A resource whose table has no createdAt (a custom one) simply has no trend.
112
+ const windows = createdAt
113
+ ? {
114
+ current: sql<number>`sum(case when ${createdAt as never} >= ${since} then 1 else 0 end)`,
115
+ previous: sql<number>`sum(case when ${createdAt as never} >= ${before} and ${createdAt as never} < ${since} then 1 else 0 end)`,
116
+ }
117
+ : { current: sql<number>`0`, previous: sql<number>`0` };
118
+
119
+ const select: Record<string, unknown> = { total: count(), ...windows };
120
+ if (grouped) select.value = grouped;
121
+ const query = getDb()
122
+ .select(select as never)
123
+ .from(entry.table);
124
+ const rows = (await (grouped ? query.groupBy(grouped as never) : query)) as {
125
+ total: number;
126
+ current: number;
127
+ previous: number;
128
+ value?: unknown;
129
+ }[];
130
+
131
+ const stats: ResourceStats = { total: 0, current: 0, previous: 0, values: {} };
132
+ for (const row of rows) {
133
+ stats.total += Number(row.total ?? 0);
134
+ stats.current += Number(row.current ?? 0);
135
+ stats.previous += Number(row.previous ?? 0);
136
+ if (grouped) stats.values[String(row.value ?? "")] = Number(row.total ?? 0);
137
+ }
138
+ return stats;
139
139
  },
140
- ["flare", "recent-count"],
141
- { tags: [resourceTag(name)], revalidate: TTL.short },
140
+ ["flare", "resource-stats"],
141
+ { tags: [resourceTag(name)], revalidate: TTL.medium },
142
142
  );
143
- return counts(name, days);
143
+ return read(name, field, days);
144
+ }
145
+
146
+ /** How many records a resource has. One field of {@link resourceStats}. */
147
+ export async function recordCount(name: string): Promise<number> {
148
+ return (await resourceStats(name)).total;
144
149
  }
145
150
 
146
151
  /** Percentage change between two periods, or undefined when there's nothing to compare. */
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Numbers as people read and type them.
3
+ *
4
+ * A price typed as 2000 is easier to check at a glance as 2,000, but the form and the
5
+ * API only ever see the plain number — the grouping is put on for display and taken off
6
+ * again on every keystroke, so nothing downstream has to unpick a comma.
7
+ *
8
+ * Kept free of server imports: the field widgets that use it run in the browser.
9
+ */
10
+
11
+ /** "2000.5" → "2,000.5". A half-typed "1," or "12." is left alone so typing isn't fought. */
12
+ export function groupDigits(value: string): string {
13
+ if (!value) return "";
14
+ const negative = value.startsWith("-");
15
+ const body = negative ? value.slice(1) : value;
16
+ const [whole = "", ...rest] = body.split(".");
17
+ const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
18
+ // Keep a trailing "." while someone is still typing the decimals.
19
+ const decimals = rest.length > 0 ? `.${rest.join("")}` : "";
20
+ return `${negative ? "-" : ""}${grouped}${decimals}`;
21
+ }
22
+
23
+ /**
24
+ * "2,000.50" → "2000.50". Everything that isn't a digit goes, along with any minus sign
25
+ * that isn't leading and any second decimal point.
26
+ */
27
+ export function ungroup(value: string, allowDecimal: boolean): string {
28
+ const negative = value.trimStart().startsWith("-");
29
+ const digits = value.replace(/[^\d.]/g, "");
30
+ const [whole = "", ...rest] = digits.split(".");
31
+ const body = allowDecimal && rest.length > 0 ? `${whole}.${rest.join("")}` : whole;
32
+ return `${negative && body ? "-" : ""}${body}`;
33
+ }
@@ -1,56 +1,56 @@
1
- import type { ThemeName } from "./theme";
2
-
3
- /**
4
- * What the site says about itself: the home page, the sign-in screens' side panels and
5
- * the page titles read from here. Edit freely.
6
- */
7
- export const site = {
8
- name: "__APP_NAME__",
9
- tagline: "The fastest way to run your business",
10
- description: "__APP_NAME__ keeps your team, your customers and your data in one place, and it's live on the edge in every region.",
11
- /**
12
- * The look of the whole app: sign-in screens, dashboard, admin and home page.
13
- * One of: default, coral, amber, sky, mono, emerald. FLARE_THEME (shell or .env)
14
- * overrides it when the app is built or run with `flare dev`.
15
- */
16
- theme: "__THEME__" as ThemeName,
17
- /** How the dashboard behaves. */
18
- dashboard: {
19
- /**
20
- * Where a record is created and edited: in a dialog over the list ("modal", the
21
- * default, which keeps the list, its filters and your place in it), or on its own
22
- * page ("page", better for long forms).
23
- */
24
- forms: "modal" as "modal" | "page",
25
- },
26
- /** The small pill above the home page headline; set to null to hide it. */
27
- announcement: { label: "New", text: "Passkeys and two-factor sign-in are here", href: "/sign-up" } as { label: string; text: string; href: string } | null,
28
- /** The home page's feature grid (icon names from lucide.dev). */
29
- features: [
30
- { icon: "zap", title: "Fast everywhere", description: "Served from the edge in every region, so pages load in milliseconds wherever your customers are." },
31
- { icon: "shield-check", title: "Secure by default", description: "Passkeys, two-factor sign-in, roles and rate limits, switched on from day one." },
32
- { icon: "layout-dashboard", title: "One place for your data", description: "Every record your team keeps, with search, filters and an admin built in." },
33
- { icon: "users", title: "Built for teams", description: "Invite your team, give each person the right access, and see who changed what." },
34
- { icon: "plug", title: "Connects to your tools", description: "A documented API for every record, ready for your scripts and integrations." },
35
- { icon: "credit-card", title: "Billing when you need it", description: "Plans, checkout and invoices through Stripe, without building a billing system." },
36
- ],
37
- /** Placeholder names for the logo strip: replace them with your customers (or remove the strip). */
38
- customers: ["Acme", "Globex", "Initech", "Vandelay", "Northwind", "Brightline"],
39
- /** Shown beside the sign-up form. */
40
- highlights: [
41
- "Set up in minutes, with nothing to install",
42
- "Secure sign-in with passkeys and two-factor",
43
- "Your data stays yours: export it any time",
44
- ],
45
- /** Shown on the sign-in screen of themes with a quote panel. */
46
- testimonial: {
47
- quote: "We moved our whole workflow over in an afternoon. The team hasn't looked back.",
48
- author: "Alex Rivera",
49
- role: "Head of Operations",
50
- },
51
- links: {
52
- terms: "/terms",
53
- privacy: "/privacy",
54
- support: "mailto:support@example.com",
55
- },
56
- };
1
+ import type { ThemeName } from "./theme";
2
+
3
+ /**
4
+ * What the site says about itself: the home page, the sign-in screens' side panels and
5
+ * the page titles read from here. Edit freely.
6
+ */
7
+ export const site = {
8
+ name: "__APP_NAME__",
9
+ tagline: "The fastest way to run your business",
10
+ description: "__APP_NAME__ keeps your team, your customers and your data in one place, and it's live on the edge in every region.",
11
+ /**
12
+ * The look of the whole app: sign-in screens, dashboard, admin and home page.
13
+ * One of: default, coral, amber, sky, mono, emerald. FLARE_THEME (shell or .env)
14
+ * overrides it when the app is built or run with `flare dev`.
15
+ */
16
+ theme: "__THEME__" as ThemeName,
17
+ /** How the dashboard behaves. */
18
+ dashboard: {
19
+ /**
20
+ * Where a record is created and edited: in a dialog over the list ("modal", the
21
+ * default, which keeps the list, its filters and your place in it), or on its own
22
+ * page ("page", better for long forms).
23
+ */
24
+ forms: "sheet" as "sheet" | "page",
25
+ },
26
+ /** The small pill above the home page headline; set to null to hide it. */
27
+ announcement: { label: "New", text: "Passkeys and two-factor sign-in are here", href: "/sign-up" } as { label: string; text: string; href: string } | null,
28
+ /** The home page's feature grid (icon names from lucide.dev). */
29
+ features: [
30
+ { icon: "zap", title: "Fast everywhere", description: "Served from the edge in every region, so pages load in milliseconds wherever your customers are." },
31
+ { icon: "shield-check", title: "Secure by default", description: "Passkeys, two-factor sign-in, roles and rate limits, switched on from day one." },
32
+ { icon: "layout-dashboard", title: "One place for your data", description: "Every record your team keeps, with search, filters and an admin built in." },
33
+ { icon: "users", title: "Built for teams", description: "Invite your team, give each person the right access, and see who changed what." },
34
+ { icon: "plug", title: "Connects to your tools", description: "A documented API for every record, ready for your scripts and integrations." },
35
+ { icon: "credit-card", title: "Billing when you need it", description: "Plans, checkout and invoices through Stripe, without building a billing system." },
36
+ ],
37
+ /** Placeholder names for the logo strip: replace them with your customers (or remove the strip). */
38
+ customers: ["Acme", "Globex", "Initech", "Vandelay", "Northwind", "Brightline"],
39
+ /** Shown beside the sign-up form. */
40
+ highlights: [
41
+ "Set up in minutes, with nothing to install",
42
+ "Secure sign-in with passkeys and two-factor",
43
+ "Your data stays yours: export it any time",
44
+ ],
45
+ /** Shown on the sign-in screen of themes with a quote panel. */
46
+ testimonial: {
47
+ quote: "We moved our whole workflow over in an afternoon. The team hasn't looked back.",
48
+ author: "Alex Rivera",
49
+ role: "Head of Operations",
50
+ },
51
+ links: {
52
+ terms: "/terms",
53
+ privacy: "/privacy",
54
+ support: "mailto:support@example.com",
55
+ },
56
+ };