app: lists on meta tables, schema-qualified SQL, typed columns

- lists read/write real lists.* tables via meta.tables/meta.columns and the
  SECURITY DEFINER meta functions; typed extra columns in the item forms
- old numeric list URLs 308-redirect to the slug
- include_recurring is boolean, osu accuracy/pp/rate numeric
- raw SQL qualified with the new schemas
This commit is contained in:
2026-10-23 22:03:42 +00:00
parent 64fb8df262
commit 4cfe6bad6c
25 changed files with 529 additions and 218 deletions

View File

@@ -3,9 +3,10 @@ import postgres from 'postgres';
// Read-only fingerprints: lets a deployment verify saved progress without logging it.
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const client = postgres(process.env.DB_URL, { max: 1 });
// All saved progress lives in schema `games` since the schema reorg.
const tables = [
'user_entries',
'user_games',
'catch_em_all_entries',
'catch_em_all_games',
'catch_em_all_settings',
'pokemon_journeys',
'pokemon_catches',
@@ -20,11 +21,11 @@ const tables = [
try {
const result: Record<string, unknown> = {};
for (const table of tables) {
const [exists] = await client`select to_regclass(${'public.' + table}) is not null as present`;
const [exists] = await client`select to_regclass(${'games.' + table}) is not null as present`;
if (!exists.present) continue;
const [row] = await client.unsafe(`select count(*)::int as rows,
md5(coalesce(string_agg(record, E'\\n' order by record), '')) as fingerprint
from (select row_to_json(t)::text as record from "${table}" t) s`);
from (select row_to_json(t)::text as record from games."${table}" t) s`);
result[table] = row;
}
console.log(JSON.stringify(result, null, 2));

View File

@@ -85,7 +85,7 @@ async function itemId(
extraDimensions: Dimensions = {}
) {
const [row] = await sql<{ id: number }[]>`
INSERT INTO cs_market_items (market_hash_name, name, icon_url, item_type)
INSERT INTO cs.items (market_hash_name, name, icon_url, item_type)
VALUES (${name}, ${displayName}, ${iconUrl ?? null}, ${itemType ?? null})
ON CONFLICT (market_hash_name) DO UPDATE SET
name = EXCLUDED.name,
@@ -98,13 +98,13 @@ async function itemId(
for (const [key, value] of Object.entries(inferred)) {
if (!/^[a-z][a-z0-9_]*$/.test(key) || !value) continue;
await sql`
INSERT INTO cs_item_dimensions (item_id, key, value) VALUES (${row.id}, ${key}, ${value})
INSERT INTO cs.item_dimensions (item_id, key, value) VALUES (${row.id}, ${key}, ${value})
ON CONFLICT (item_id, key) DO NOTHING`;
}
for (const [key, value] of Object.entries(extraDimensions)) {
if (!/^[a-z][a-z0-9_]*$/.test(key) || !value) continue;
await sql`
INSERT INTO cs_item_dimensions (item_id, key, value) VALUES (${row.id}, ${key}, ${value})
INSERT INTO cs.item_dimensions (item_id, key, value) VALUES (${row.id}, ${key}, ${value})
ON CONFLICT (item_id, key) DO UPDATE SET value = EXCLUDED.value`;
}
return row.id;
@@ -116,7 +116,7 @@ async function saveObservation(row: Observation, knownItemId?: number) {
(await itemId(row.marketHashName, row.name, undefined, undefined, row.dimensions));
await ensureStream(row.source, row.currency, row.kind);
await sql`
INSERT INTO cs_price_observations
INSERT INTO cs.price_observations
(item_id, source, currency, observed_at, price_cents, volume, kind)
VALUES (${item}, ${row.source}, ${row.currency}, ${new Date(row.observedAt)},
${row.priceCents}, ${row.volume ?? null}, ${row.kind})
@@ -128,7 +128,7 @@ async function saveObservation(row: Observation, knownItemId?: number) {
async function ensureStream(source: string, currency: string, kind: string) {
const key = `${source}\0${currency}\0${kind}`;
if (knownStreams.has(key)) return;
await sql`INSERT INTO cs_price_streams (source, currency, kind)
await sql`INSERT INTO cs.price_streams (source, currency, kind)
VALUES (${source}, ${currency}, ${kind}) ON CONFLICT DO NOTHING`;
knownStreams.add(key);
}
@@ -150,7 +150,7 @@ async function saveHistoryRows(
kind: 'sale-median'
}));
for (let offset = 0; offset < rows.length; offset += 500) {
await sql`INSERT INTO cs_price_observations ${sql(rows.slice(offset, offset + 500))}
await sql`INSERT INTO cs.price_observations ${sql(rows.slice(offset, offset + 500))}
ON CONFLICT (item_id, source, currency, observed_at, kind) DO UPDATE SET
price_cents = EXCLUDED.price_cents, volume = EXCLUDED.volume`;
}
@@ -242,7 +242,7 @@ async function history() {
if (!process.env.STEAM_SESSIONID || !process.env.STEAM_LOGIN_SECURE)
throw new Error('STEAM_SESSIONID and STEAM_LOGIN_SECURE are required for Steam history');
const items = await sql<{ id: number; market_hash_name: string }[]>`
SELECT id, market_hash_name FROM cs_market_items ORDER BY id LIMIT ${limit} OFFSET ${start}`;
SELECT id, market_hash_name FROM cs.items ORDER BY id LIMIT ${limit} OFFSET ${start}`;
let done = 0;
for (const item of items) {
const url = new URL('https://steamcommunity.com/market/pricehistory/');

View File

@@ -8,11 +8,11 @@ try {
await sql.begin(async (tx) => {
for (const image of artwork) {
const rows =
await tx`update tracker_items i set icon=${image.path} from tracker_games g where i.game_id=g.id and g.slug=${image.game} and i.key=${image.key} returning i.id`;
await tx`update ref.tracker_items i set icon=${image.path} from ref.tracker_games g where i.game_id=g.id and g.slug=${image.game} and i.key=${image.key} returning i.id`;
if (rows.length !== 1) throw new Error(`Unknown artwork target: ${image.game}/${image.key}`);
}
for (const [source, path] of Object.entries(achievementArt)) {
await tx`update tracker_items set icon=${path} where kind='achievement' and icon=${source}`;
await tx`update ref.tracker_items set icon=${path} where kind='achievement' and icon=${source}`;
}
});
console.log(

View File

@@ -22,17 +22,17 @@ try {
}[]
][]) {
const [game] =
await tx`select id from tracker_games where slug=${slug} and franchise='call-of-duty'`;
await tx`select id from ref.tracker_games where slug=${slug} and franchise='call-of-duty'`;
if (!game) throw new Error(`Seed Call of Duty catalog first: ${slug}`);
for (let offset = 0; offset < rows.length; offset += 500) {
const batch = rows
.slice(offset, offset + 500)
.map(({ sourceUrl, ...r }) => ({ ...r, game_id: game.id, source_url: sourceUrl }));
await tx`insert into tracker_challenges ${tx(batch)} on conflict (game_id,key) do update set name=excluded.name,category=excluded.category,requirement=excluded.requirement,reward=excluded.reward,icon=excluded.icon,target=excluded.target,source_url=excluded.source_url`;
await tx`insert into ref.tracker_challenges ${tx(batch)} on conflict (game_id,key) do update set name=excluded.name,category=excluded.category,requirement=excluded.requirement,reward=excluded.reward,icon=excluded.icon,target=excluded.target,source_url=excluded.source_url`;
}
// Retire removed source definitions only when nobody has saved progress.
if (rows.length)
await tx`delete from tracker_challenges c where c.game_id=${game.id} and c.key not in ${tx(rows.map((r) => r.key))} and not exists (select 1 from tracker_challenge_progress p where p.challenge_id=c.id)`;
await tx`delete from ref.tracker_challenges c where c.game_id=${game.id} and c.key not in ${tx(rows.map((r) => r.key))} and not exists (select 1 from games.tracker_challenge_progress p where p.challenge_id=c.id)`;
console.log(`${slug}: ${rows.length} challenge rows`);
}
});

View File

@@ -9,10 +9,10 @@ const client = postgres(process.env.DB_URL, { max: 1 });
try {
await client.begin(async (tx) => {
const species = new Map(
(await tx`select id, number from pokemon`).map((r) => [r.number, r.id])
(await tx`select id, number from ref.pokemon`).map((r) => [r.number, r.id])
);
const games = new Map(
(await tx`select id, source_id from games`).map((r) => [r.source_id, r.id])
(await tx`select id, source_id from ref.pokemon_games`).map((r) => [r.source_id, r.id])
);
const speciesId = (n: number) => {
const id = species.get(n);
@@ -25,10 +25,10 @@ try {
return id;
};
for (const row of catalog.facts)
await tx`insert into pokemon_research (pokemon_id,facts) values (${speciesId(row.number)},${tx.json(row.facts)}) on conflict (pokemon_id) do update set facts=excluded.facts`;
await tx`insert into ref.pokemon_research (pokemon_id,facts) values (${speciesId(row.number)},${tx.json(row.facts)}) on conflict (pokemon_id) do update set facts=excluded.facts`;
for (const row of catalog.games) {
if (!games.has(row.sourceId)) continue;
await tx`insert into pokemon_game_research (game_id,version_group,regions) values (${gameId(row.sourceId)},${row.versionGroup},${row.regions}) on conflict (game_id) do update set version_group=excluded.version_group,regions=excluded.regions`;
await tx`insert into ref.pokemon_game_research (game_id,version_group,regions) values (${gameId(row.sourceId)},${row.versionGroup},${row.regions}) on conflict (game_id) do update set version_group=excluded.version_group,regions=excluded.regions`;
}
for (let offset = 0; offset < catalog.dexes.length; offset += 1000) {
const batch = catalog.dexes
@@ -42,10 +42,10 @@ try {
number
}));
if (batch.length)
await tx`insert into pokemon_dex_numbers ${tx(batch)} on conflict (pokemon_id,game_id,dex) do update set number=excluded.number,dex_name=excluded.dex_name`;
await tx`insert into ref.pokemon_dex_numbers ${tx(batch)} on conflict (pokemon_id,game_id,dex) do update set number=excluded.number,dex_name=excluded.dex_name`;
}
for (const row of catalog.moves)
await tx`insert into pokemon_moves ${tx(row)} on conflict (id) do update set name=excluded.name,type=excluded.type,category=excluded.category,power=excluded.power,accuracy=excluded.accuracy,pp=excluded.pp,priority=excluded.priority,target=excluded.target`;
await tx`insert into ref.pokemon_moves ${tx(row)} on conflict (id) do update set name=excluded.name,type=excluded.type,category=excluded.category,power=excluded.power,accuracy=excluded.accuracy,pp=excluded.pp,priority=excluded.priority,target=excluded.target`;
for (let offset = 0; offset < catalog.learnsets.length; offset += 500) {
const batch = catalog.learnsets
.slice(offset, offset + 500)
@@ -54,7 +54,7 @@ try {
version_group,
moves: tx.json(moves)
}));
await tx`insert into pokemon_learnsets ${tx(batch)} on conflict (pokemon_id,version_group) do update set moves=excluded.moves`;
await tx`insert into ref.pokemon_learnsets ${tx(batch)} on conflict (pokemon_id,version_group) do update set moves=excluded.moves`;
}
console.log(
`Seeded ${catalog.facts.length} profiles, ${catalog.dexes.length} dex entries, ${catalog.moves.length} moves and ${catalog.learnsets.length} versioned learnsets`

View File

@@ -6,7 +6,7 @@ if (!process.env.DB_URL) throw new Error('DB_URL is required');
const sql = postgres(process.env.DB_URL, { max: 1 });
try {
const users =
await sql`update "user" set role = ${role}, updated_at = now() where id = ${userId} returning id, role`;
await sql`update auth."user" set role = ${role}, updated_at = now() where id = ${userId} returning id, role`;
if (!users.length)
throw new Error('User ID not found. Register first and copy the ID from your account page.');
console.log(users[0]);

View File

@@ -0,0 +1,59 @@
<script lang="ts">
// Form inputs for a list's own typed columns (meta.columns), posted as `extra:<column>`.
import { Input } from '#lib/components/ui/input/index.js';
import { Textarea } from '#lib/components/ui/textarea/index.js';
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
import { Checkbox } from '#lib/components/ui/checkbox/index.js';
import type { ExtraValue, ListColumn } from '#lib/server/lists.js';
let { columns, values = {} }: { columns: ListColumn[]; values?: Record<string, ExtraValue> } =
$props();
const str = (v: ExtraValue | undefined) => (v === null || v === undefined ? '' : String(v));
</script>
{#each columns as column (column.name)}
{@const name = `extra:${column.name}`}
{#if column.uiType === 'bool'}
<label class="text-sm"
><Checkbox {name} value="on" checked={values[column.name] === true} /> {column.label}</label
>
{:else if column.uiType === 'select' && column.options.length}
<label
>{column.label}<NativeSelect
{name}
class="block w-full rounded border p-2"
value={str(values[column.name])}
>
<option value="">—</option>
{#each column.options as option (option)}<option value={option}>{option}</option>{/each}
</NativeSelect></label
>
{:else if column.uiType === 'longtext'}
<label
>{column.label}<Textarea
{name}
rows={3}
maxlength={2000}
class="block w-full rounded border p-2"
value={str(values[column.name])}
/></label
>
{:else if ['text', 'url', 'image', 'number', 'cents', 'date', 'select'].includes(column.uiType)}
<label
>{column.label}<Input
{name}
type={column.uiType === 'number' || column.uiType === 'cents'
? 'number'
: column.uiType === 'date'
? 'date'
: column.uiType === 'url' || column.uiType === 'image'
? 'url'
: 'text'}
step={column.uiType === 'number' ? 'any' : undefined}
maxlength={column.uiType === 'text' ? 2000 : undefined}
class="block w-full rounded border p-2"
value={str(values[column.name])}
/></label
>
{/if}
{/each}

View File

@@ -94,7 +94,7 @@ describe('money tracker calculations', () => {
annualReturnPercent: '5.00',
contributionMode: 'excess',
fixedMonthlyCents: 0,
includeRecurring: 1,
includeRecurring: true,
endDate: '2026-11-30'
};
const accounts = [
@@ -140,7 +140,7 @@ describe('money tracker calculations', () => {
annualReturnPercent: '5.00',
contributionMode: 'excess',
fixedMonthlyCents: 0,
includeRecurring: 1,
includeRecurring: true,
endDate: '2026-11-30'
},
'2026-09-23'

View File

@@ -101,7 +101,7 @@ type ProjectionSettings = {
annualReturnPercent: string | number;
contributionMode: string;
fixedMonthlyCents: number;
includeRecurring: number;
includeRecurring: boolean;
endDate?: string | null;
};
@@ -160,7 +160,7 @@ export function wealthProjection(
if (entry.accountId === validInvestmentId) indexBalance += entry.amountCents;
} else spendable += entry.amountCents;
}
if (settings?.includeRecurring !== 0 && index > 0) {
if (settings?.includeRecurring !== false && index > 0) {
for (const item of recurring) {
if ((item.startsOn && item.startsOn > date) || (item.endsOn && item.endsOn < first))
continue;

296
src/lib/server/lists.ts Normal file
View File

@@ -0,0 +1,296 @@
// Generic list engine. Every list is a real table in schema `lists`, registered
// in meta.tables / meta.columns. Table and column names only ever come from the
// registry (written by the SECURITY DEFINER meta.* functions, which validate
// identifiers) and are re-checked here before being used as identifiers.
import { and, asc, desc, eq, isNull, max, notInArray, sql } from 'drizzle-orm';
import { db } from '#lib/server/db/index.js';
import {
metaColumns,
metaTables,
type MetaTable,
type UiType
} from '#lib/server/db/schema/index.js';
const IDENT = /^[a-z][a-z0-9_]{0,62}$/;
/** Columns every list has; everything else is a list-specific extra column. */
const STANDARD = [
'id',
'name',
'notes',
'picture_url',
'done_at',
'sort_order',
'created_at',
'updated_at'
];
export type ExtraValue = string | number | boolean | null;
export type ListColumn = { name: string; label: string; uiType: UiType; options: string[] };
export type ListItem = {
id: number;
name: string;
description: string | null;
pictureUrl: string | null;
doneAt: Date | null;
extras: Record<string, ExtraValue>;
};
export type ListSummary = {
id: number;
name: string;
slug: string;
pictureUrl: string | null;
isProgress: boolean;
total: number;
checked: number;
};
export type ListView = Omit<ListSummary, 'total' | 'checked'> & {
columns: ListColumn[];
items: ListItem[];
};
function table(t: Pick<MetaTable, 'schemaName' | 'tableName'>) {
if (t.schemaName !== 'lists' || !IDENT.test(t.tableName))
throw new Error(`refusing unexpected list table ${t.schemaName}.${t.tableName}`);
return sql`${sql.identifier('lists')}.${sql.identifier(t.tableName)}`;
}
const activeList = and(eq(metaTables.kind, 'list'), isNull(metaTables.archivedAt));
export async function findList(slug: string) {
const [row] = await db
.select()
.from(metaTables)
.where(and(activeList, eq(metaTables.slug, slug)));
return row ?? null;
}
export async function listSummaries(): Promise<ListSummary[]> {
const rows = await db
.select()
.from(metaTables)
.where(activeList)
.orderBy(desc(metaTables.sortOrder), desc(metaTables.id));
if (!rows.length) return [];
const counts = await db.execute<{ id: number; total: number; checked: number }>(
sql.join(
rows.map(
(t) =>
sql`SELECT ${t.id}::int AS id, count(*)::int AS total, ${
t.hasDone ? sql`count(done_at)::int` : sql`0`
} AS checked FROM ${table(t)}`
),
sql` UNION ALL `
)
);
const byId = new Map([...counts].map((c) => [Number(c.id), c]));
return rows.map((t) => ({
id: t.id,
name: t.displayName,
slug: t.slug,
pictureUrl: t.pictureUrl,
isProgress: t.hasDone,
total: byId.get(t.id)?.total ?? 0,
checked: byId.get(t.id)?.checked ?? 0
}));
}
export async function listColumns(t: MetaTable): Promise<ListColumn[]> {
const rows = await db
.select()
.from(metaColumns)
.where(
and(
eq(metaColumns.tableId, t.id),
eq(metaColumns.hidden, false),
notInArray(metaColumns.columnName, STANDARD)
)
)
.orderBy(asc(metaColumns.position));
return rows
.filter((c) => IDENT.test(c.columnName))
.map((c) => ({
name: c.columnName,
label: c.label ?? c.columnName.replace(/_/g, ' '),
uiType: c.uiType,
options: c.options.values ?? []
}));
}
type Row = Record<string, unknown> & { id: number; name: string };
function toItem(row: Row, columns: ListColumn[]): ListItem {
const extras: Record<string, ExtraValue> = {};
for (const c of columns) {
const v = row[c.name];
extras[c.name] = v instanceof Date ? v.toISOString() : ((v ?? null) as ExtraValue);
}
return {
id: Number(row.id),
name: row.name,
description: (row.notes as string | null) ?? null,
pictureUrl: (row.picture_url as string | null) ?? null,
doneAt: row.done_at ? new Date(row.done_at as string) : null,
extras
};
}
export async function getList(slug: string): Promise<ListView | null> {
const t = await findList(slug);
if (!t) return null;
const columns = await listColumns(t);
const rows = await db.execute<Row>(sql`SELECT * FROM ${table(t)} ORDER BY sort_order, id`);
return {
id: t.id,
name: t.displayName,
slug: t.slug,
pictureUrl: t.pictureUrl,
isProgress: t.hasDone,
columns,
items: [...rows].map((r) => toItem(r, columns))
};
}
/** Parse the `extra:<column>` form fields for this list's columns. */
export function extraFields(
form: FormData,
columns: ListColumn[]
): { values: Record<string, ExtraValue> } | { error: string } {
const values: Record<string, ExtraValue> = {};
for (const c of columns) {
const key = `extra:${c.name}`;
if (c.uiType === 'bool') {
values[c.name] = form.get(key) === 'on';
continue;
}
if (!form.has(key)) continue;
const raw = String(form.get(key) ?? '').trim();
if (!raw) {
values[c.name] = null;
continue;
}
if (raw.length > 2000) return { error: `${c.label} is too long.` };
switch (c.uiType) {
case 'number':
case 'cents': {
const n = Number(raw);
if (!Number.isFinite(n) || (c.uiType === 'cents' && !Number.isInteger(n)))
return { error: `${c.label} must be a number.` };
values[c.name] = n;
break;
}
case 'select':
if (c.options.length && !c.options.includes(raw))
return { error: `${c.label} must be one of: ${c.options.join(', ')}.` };
values[c.name] = raw;
break;
case 'url':
case 'image':
if (!/^https?:\/\/\S+$/.test(raw)) return { error: `${c.label} must be an http(s) URL.` };
values[c.name] = raw;
break;
case 'date':
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) return { error: `${c.label} must be a date.` };
values[c.name] = raw;
break;
case 'text':
case 'longtext':
values[c.name] = raw;
break;
default:
// json / multiselect / fk are not editable from this form yet
break;
}
}
return { values };
}
function columnsAndValues(values: Record<string, ExtraValue>) {
const entries = Object.entries(values).filter(([k]) => IDENT.test(k));
return {
cols: entries.map(([k]) => sql.identifier(k)),
vals: entries.map(([, v]) => sql`${v}`),
sets: entries.map(([k, v]) => sql`${sql.identifier(k)} = ${v}`)
};
}
export type ItemValues = {
name: string;
description: string | null;
pictureUrl?: string | null;
extras: Record<string, ExtraValue>;
};
function rowValues(v: ItemValues): Record<string, ExtraValue> {
return {
...v.extras,
name: v.name,
notes: v.description,
...('pictureUrl' in v ? { picture_url: v.pictureUrl ?? null } : {})
};
}
export async function insertItem(t: MetaTable, v: ItemValues) {
const { cols, vals } = columnsAndValues(rowValues(v));
const tbl = table(t);
await db.execute(
sql`INSERT INTO ${tbl} (${sql.join(cols, sql`, `)}, sort_order)
VALUES (${sql.join(vals, sql`, `)}, (SELECT coalesce(max(sort_order), 0) + 1 FROM ${tbl}))`
);
}
export async function getItem(t: MetaTable, id: number) {
const [row] = await db.execute<Row>(sql`SELECT * FROM ${table(t)} WHERE id = ${id}`);
return row ? toItem(row, []) : null;
}
export async function updateItem(t: MetaTable, id: number, v: ItemValues) {
const { sets } = columnsAndValues(rowValues(v));
const rows = await db.execute<{ id: number }>(
sql`UPDATE ${table(t)} SET ${sql.join(sets, sql`, `)} WHERE id = ${id} RETURNING id`
);
return rows.length > 0;
}
export async function deleteItem(t: MetaTable, id: number) {
const [row] = await db.execute<{ picture_url: string | null }>(
sql`DELETE FROM ${table(t)} WHERE id = ${id} RETURNING picture_url`
);
return row ?? null;
}
export async function setDone(t: MetaTable, id: number, done: boolean) {
if (!t.hasDone) throw new Error('not a progress list');
const rows = await db.execute<{ id: number }>(
sql`UPDATE ${table(t)} SET done_at = ${done ? sql`coalesce(done_at, now())` : sql`NULL`}
WHERE id = ${id} RETURNING id`
);
return rows.length > 0;
}
/** Create a new runtime list (a real table) through the meta API. */
export async function createList(input: {
slug: string;
name: string;
isProgress: boolean;
pictureUrl: string | null;
}) {
let base = input.slug.replace(/-/g, '_').replace(/^[^a-z]+/, '');
base = (base || 'list').slice(0, 55);
let tableName = base;
for (let n = 2; ; n++) {
const [taken] = await db
.select({ id: metaTables.id })
.from(metaTables)
.where(and(eq(metaTables.schemaName, 'lists'), eq(metaTables.tableName, tableName)));
if (!taken) break;
tableName = `${base}_${n}`;
}
const [{ top }] = await db.select({ top: max(metaTables.sortOrder) }).from(metaTables);
await db.execute(
sql`SELECT meta.create_list(${input.slug}, ${tableName}, ${input.name}, ${input.isProgress},
'[]'::jsonb, ${input.pictureUrl}, NULL, NULL, ${(top ?? 0) + 1})`
);
}
/** Soft delete: the table stays for 7 days (meta.drop_list refuses earlier). */
export async function archiveList(slug: string) {
await db.execute(sql`SELECT meta.archive_list(${slug})`);
}

View File

@@ -1,5 +1,5 @@
import { db } from '#lib/server/db/index.js';
import { blogArticles, lists } from '#lib/server/db/schema/index.js';
import { blogArticles, metaTables } from '#lib/server/db/schema/index.js';
import { eq } from 'drizzle-orm';
export function slugify(title: string, fallback = 'article'): string {
@@ -32,7 +32,10 @@ export async function uniqueListSlug(name: string): Promise<string> {
const base = slugify(name, 'list');
let slug = base;
let suffix = 2;
while ((await db.select({ id: lists.id }).from(lists).where(eq(lists.slug, slug))).length > 0) {
while (
(await db.select({ id: metaTables.id }).from(metaTables).where(eq(metaTables.slug, slug)))
.length > 0
) {
slug = `${base}-${suffix++}`;
}
return slug;

View File

@@ -1,33 +1,10 @@
import { fail } from '@sveltejs/kit';
import { db } from '#lib/server/db/index.js';
import { listItemCompletions } from '#lib/server/db/schema/index.js';
import { eq } from 'drizzle-orm';
import { listSummaries } from '#lib/server/lists.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
const lists = await db.query.lists.findMany({
columns: { id: true, name: true, slug: true, isProgress: true, pictureUrl: true },
with: { items: { columns: { id: true } } },
orderBy: { id: 'desc' }
});
const completed = new Set(
locals.user
? (
await db
.select({ itemId: listItemCompletions.itemId })
.from(listItemCompletions)
.where(eq(listItemCompletions.userId, locals.user.id))
).map((row) => row.itemId)
: []
);
return {
lists: lists.map(({ items, ...list }) => ({
...list,
total: items.length,
checked: list.isProgress ? items.filter((item) => completed.has(item.id)).length : 0
}))
};
export const load: PageServerLoad = async () => {
// `done` is a property of the item now (one owner), so progress is the same for every visitor.
return { lists: await listSummaries() };
};
// Retire the old shared-token login; legacy cookies grant no permissions.

View File

@@ -1,35 +1,11 @@
import { db } from '#lib/server/db/index.js';
import { listItemCompletions, lists } from '#lib/server/db/schema/index.js';
import { requireAdmin } from '#lib/server/authorization.js';
import { uniqueListSlug } from '#lib/server/slugify.js';
import { listImageUrl } from '#lib/server/r2.js';
import { eq } from 'drizzle-orm';
import { createList, listSummaries } from '#lib/server/lists.js';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
const rows = await db.query.lists.findMany({
with: { items: { columns: { id: true } } },
orderBy: { id: 'desc' }
});
const completed = new Set(
locals.user
? (
await db
.select({ itemId: listItemCompletions.itemId })
.from(listItemCompletions)
.where(eq(listItemCompletions.userId, locals.user.id))
).map((row) => row.itemId)
: []
);
return {
lists: rows.map(({ items, ...list }) => ({
...list,
total: items.length,
checked: list.isProgress ? items.filter((item) => completed.has(item.id)).length : 0
}))
};
};
export const load: PageServerLoad = async () => ({ lists: await listSummaries() });
export const actions: Actions = {
newList: async (event) => {
@@ -47,8 +23,9 @@ export const actions: Actions = {
return fail(400, { message: 'Enable JavaScript to upload an image.' });
const pictureUrl = pictureKey ? listImageUrl(pictureKey) : null;
if (pictureKey && !pictureUrl) return fail(400, { message: 'Invalid uploaded picture.' });
const slug = await uniqueListSlug(name);
await db.insert(lists).values({ name, slug, isProgress: type === 'progress', pictureUrl });
// meta.tables.slug allows at most 63 characters
const slug = (await uniqueListSlug(name.slice(0, 60))).slice(0, 63).replace(/-+$/, '');
await createList({ slug, name, isProgress: type === 'progress', pictureUrl });
redirect(303, `/lists/${slug}`);
}
};

View File

@@ -123,7 +123,7 @@
>
{/if}
<span class="min-w-16 whitespace-nowrap text-right text-base text-[var(--site-muted)]">
{#if list.isProgress && data.user}
{#if list.isProgress}
{list.total ? Math.round((list.checked / list.total) * 100) : 0}%
{:else}
{list.total} {list.total === 1 ? 'item' : 'items'}

View File

@@ -1,15 +1,28 @@
import { db } from '#lib/server/db/index.js';
import { ORIGIN } from '$app/env/private';
import { lists, listItems, listItemCompletions } from '#lib/server/db/schema/index.js';
import { requireAdmin, requireUser } from '#lib/server/authorization.js';
import { metaTables } from '#lib/server/db/schema/index.js';
import { requireAdmin } from '#lib/server/authorization.js';
import { deleteObject, listImageKey, listImageUrl } from '#lib/server/r2.js';
import { validSlug } from '#lib/server/slugify.js';
import {
archiveList,
deleteItem,
extraFields,
findList,
getItem,
getList,
insertItem,
listColumns,
setDone,
updateItem,
type ListColumn
} from '#lib/server/lists.js';
import { and, eq, ne } from 'drizzle-orm';
import { error, fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
async function currentList(slug: string) {
const list = await db.query.lists.findFirst({ where: { slug } });
const list = await findList(slug);
if (!list) error(404, 'List not found');
return list;
}
@@ -24,7 +37,7 @@ async function removeStoredPicture(url: string | null) {
console.error('Could not remove list picture from R2', cause);
}
}
function itemFields(form: FormData) {
function itemFields(form: FormData, columns: ListColumn[]) {
const name = String(form.get('name') ?? '').trim();
const description = String(form.get('description') ?? '').trim();
const pictureKey = String(form.get('pictureKey') ?? '').trim();
@@ -37,54 +50,51 @@ function itemFields(form: FormData) {
if (pictureKey && removePicture) return { error: 'Choose a new picture or remove the old one.' };
const pictureUrl = pictureKey ? listImageUrl(pictureKey) : null;
if (pictureKey && !pictureUrl) return { error: 'Invalid uploaded picture.' };
const extras = extraFields(form, columns);
if ('error' in extras) return { error: extras.error };
return {
values: {
name,
description: description || null,
extras: extras.values,
...(pictureKey ? { pictureUrl } : removePicture ? { pictureUrl: null } : {})
}
};
}
export const load: PageServerLoad = async ({ params, locals }) => {
function itemId(form: FormData) {
const id = Number(form.get('itemId'));
return Number.isSafeInteger(id) && id > 0 ? id : null;
}
export const load: PageServerLoad = async ({ params }) => {
if (/^[1-9]\d*$/.test(params.slug)) {
// Old numeric list URLs: list ids were preserved as meta.tables.sort_order.
const oldId = Number(params.slug);
if (Number.isSafeInteger(oldId)) {
const oldList = await db.query.lists.findFirst({ where: { id: oldId } });
if (oldList) redirect(308, `/lists/${oldList.slug}`);
const [old] = await db
.select({ slug: metaTables.slug })
.from(metaTables)
.where(and(eq(metaTables.kind, 'list'), eq(metaTables.sortOrder, oldId)));
if (old) redirect(308, `/lists/${old.slug}`);
}
}
const list = await db.query.lists.findFirst({
where: { slug: params.slug },
with: { items: { orderBy: { id: 'asc' } } }
});
const list = await getList(params.slug);
if (!list) error(404, 'List not found');
const completedIds =
locals.user && list.isProgress
? (
await db
.select({ itemId: listItemCompletions.itemId })
.from(listItemCompletions)
.where(eq(listItemCompletions.userId, locals.user.id))
).map((row) => row.itemId)
: [];
const inList = completedIds.filter((id) => list.items.some((item) => item.id === id));
return { list, completedIds: inList, checked: inList.length, total: list.items.length };
const completedIds = list.isProgress
? list.items.filter((item) => item.doneAt).map((item) => item.id)
: [];
return { list, completedIds, checked: completedIds.length, total: list.items.length };
};
export const actions: Actions = {
deleteList: async (event) => {
requireAdmin(event);
const list = await currentList(event.params.slug);
const confirmation = String((await event.request.formData()).get('confirmName') ?? '');
if (confirmation !== list.name)
if (confirmation !== list.displayName)
return fail(400, { message: 'Type the exact list name to confirm deletion.' });
const pictures = await db
.select({ pictureUrl: listItems.pictureUrl })
.from(listItems)
.where(eq(listItems.listId, list.id));
await db.delete(lists).where(eq(lists.id, list.id));
await Promise.all(
[...pictures.map((item) => item.pictureUrl), list.pictureUrl].map(removeStoredPicture)
);
// Archived, not dropped: the table and its pictures stay restorable for 7 days.
await archiveList(list.slug);
redirect(303, '/lists');
},
updateList: async (event) => {
@@ -94,15 +104,12 @@ export const actions: Actions = {
const slug = String(form.get('slug') ?? '')
.trim()
.toLowerCase();
const type = String(form.get('type') ?? '');
const pictureKey = String(form.get('pictureKey') ?? '').trim();
const removePicture = form.get('removePicture') === 'on';
const picture = form.get('picture');
if (!name || name.length > 200)
return fail(400, { message: 'Enter a name of 1–200 characters' });
if (type !== 'normal' && type !== 'progress')
return fail(400, { message: 'Choose a list type.' });
if (!validSlug(slug))
if (!validSlug(slug) || slug.length > 63)
return fail(400, { message: 'Use a lowercase slug with letters, numbers, and hyphens.' });
if (picture instanceof File && picture.size > 0)
return fail(400, { message: 'Enable JavaScript to upload an image.' });
@@ -112,20 +119,19 @@ export const actions: Actions = {
if (pictureKey && !pictureUrl) return fail(400, { message: 'Invalid uploaded picture.' });
const current = await currentList(event.params.slug);
const duplicate = await db
.select({ id: lists.id })
.from(lists)
.where(and(eq(lists.slug, slug), ne(lists.id, current.id)));
.select({ id: metaTables.id })
.from(metaTables)
.where(and(eq(metaTables.slug, slug), ne(metaTables.id, current.id)));
if (duplicate.length) return fail(409, { message: 'That list slug is already in use.' });
const [list] = await db
.update(lists)
.update(metaTables)
.set({
name,
displayName: name,
slug,
isProgress: type === 'progress',
...(pictureKey ? { pictureUrl } : removePicture ? { pictureUrl: null } : {})
})
.where(eq(lists.id, current.id))
.returning();
.where(eq(metaTables.id, current.id))
.returning({ pictureUrl: metaTables.pictureUrl });
if (!list) error(404, 'List not found');
if (list.pictureUrl !== current.pictureUrl) await removeStoredPicture(current.pictureUrl);
if (slug !== event.params.slug) redirect(303, `/lists/${slug}`);
@@ -133,66 +139,48 @@ export const actions: Actions = {
},
addItem: async (event) => {
requireAdmin(event);
const fields = itemFields(await event.request.formData());
if (fields.error) return fail(400, { message: fields.error });
const list = await currentList(event.params.slug);
await db.insert(listItems).values({ ...fields.values!, listId: list.id });
const fields = itemFields(await event.request.formData(), await listColumns(list));
if (fields.error) return fail(400, { message: fields.error });
await insertItem(list, fields.values!);
return { saved: true };
},
updateItem: async (event) => {
requireAdmin(event);
const form = await event.request.formData();
const id = Number(form.get('itemId'));
if (!Number.isSafeInteger(id) || id <= 0) return fail(400, { message: 'Invalid item.' });
const fields = itemFields(form);
if (fields.error) return fail(400, { message: fields.error });
const id = itemId(form);
if (!id) return fail(400, { message: 'Invalid item.' });
const list = await currentList(event.params.slug);
const existing = await db.query.listItems.findFirst({ where: { id, listId: list.id } });
const fields = itemFields(form, await listColumns(list));
if (fields.error) return fail(400, { message: fields.error });
const existing = await getItem(list, id);
if (!existing) error(404, 'Item not found');
const [item] = await db
.update(listItems)
.set(fields.values!)
.where(and(eq(listItems.id, id), eq(listItems.listId, list.id)))
.returning({ id: listItems.id });
if (!item) error(404, 'Item not found');
if (!(await updateItem(list, id, fields.values!))) error(404, 'Item not found');
if ('pictureUrl' in fields.values! && fields.values!.pictureUrl !== existing.pictureUrl)
await removeStoredPicture(existing.pictureUrl);
return { saved: true };
},
removeItem: async (event) => {
requireAdmin(event);
const id = Number((await event.request.formData()).get('itemId'));
if (!Number.isSafeInteger(id) || id <= 0) return fail(400, { message: 'Invalid item' });
const id = itemId(await event.request.formData());
if (!id) return fail(400, { message: 'Invalid item' });
const list = await currentList(event.params.slug);
const [item] = await db
.delete(listItems)
.where(and(eq(listItems.id, id), eq(listItems.listId, list.id)))
.returning({ pictureUrl: listItems.pictureUrl });
const item = await deleteItem(list, id);
if (!item) error(404, 'Item not found');
await removeStoredPicture(item.pictureUrl);
await removeStoredPicture(item.picture_url);
return { saved: true };
},
setCompletion: async (event) => {
const user = requireUser(event);
// One owner: `done` lives on the item, so only the admin can tick items off.
requireAdmin(event);
const list = await currentList(event.params.slug);
if (!list.isProgress) return fail(400, { message: 'This is not a progress list.' });
if (!list.hasDone) return fail(400, { message: 'This is not a progress list.' });
const form = await event.request.formData();
const id = Number(form.get('itemId'));
const id = itemId(form);
const completed = form.get('completed');
if (!Number.isSafeInteger(id) || id <= 0 || (completed !== 'yes' && completed !== 'no'))
if (!id || (completed !== 'yes' && completed !== 'no'))
return fail(400, { message: 'Invalid progress change.' });
const item = await db.query.listItems.findFirst({ where: { id, listId: list.id } });
if (!item) error(404, 'Item not found');
if (completed === 'yes') {
await db
.insert(listItemCompletions)
.values({ itemId: id, userId: user.id })
.onConflictDoNothing();
} else {
await db
.delete(listItemCompletions)
.where(and(eq(listItemCompletions.itemId, id), eq(listItemCompletions.userId, user.id)));
}
if (!(await setDone(list, id, completed === 'yes'))) error(404, 'Item not found');
return { saved: true };
}
};

View File

@@ -1,5 +1,4 @@
<script lang="ts">
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
import { Input } from '#lib/components/ui/input/index.js';
import { Textarea } from '#lib/components/ui/textarea/index.js';
import { Checkbox } from '#lib/components/ui/checkbox/index.js';
@@ -14,6 +13,7 @@
import { enhance, type SubmitFunction } from '$app/forms';
import ListPictureDropzone from '#lib/components/ListPictureDropzone.svelte';
import ProgressBar from '#lib/components/ProgressBar.svelte';
import ListExtraFields from '#lib/components/ListExtraFields.svelte';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
let completed = $derived(new Set(data.completedIds));
@@ -109,23 +109,16 @@
{#if form?.message}<p role="alert" class="text-red-700">{form.message}</p>{/if}
{#if uploadError}<p role="alert" class="text-red-700">{uploadError}</p>{/if}
{#if data.list.isProgress}
{#if data.user}
<div class="border-y border-[var(--site-border)] py-4">
<p class="font-medium">
{data.checked} of {data.total} checked · {data.total
? Math.round((data.checked / data.total) * 100)
: 0}%
</p>
<div class="mt-2">
<ProgressBar value={data.checked} max={data.total} label="List progress" small />
</div>
</div>
{:else}
<p>
<a class="underline" href={resolve(`login?returnTo=/lists/${data.list.slug}`)}>Log in</a> to save
your progress.
<div class="border-y border-[var(--site-border)] py-4">
<p class="font-medium">
{data.checked} of {data.total} checked · {data.total
? Math.round((data.checked / data.total) * 100)
: 0}%
</p>
{/if}
<div class="mt-2">
<ProgressBar value={data.checked} max={data.total} label="List progress" small />
</div>
</div>
{/if}
{#if data.isAdmin && managing}
<Collapsible.Root class="border-b border-[var(--site-border)] pb-5">
@@ -159,12 +152,9 @@
class="block w-full rounded border p-2"
/></label
>
<label
>Type<NativeSelect name="type" class="block w-full rounded border p-2">
<option value="normal" selected={!data.list.isProgress}>Normal</option>
<option value="progress" selected={data.list.isProgress}>Progress</option>
</NativeSelect></label
>
<p class="self-end text-sm text-[var(--site-muted)]">
{data.list.isProgress ? 'Progress' : 'Normal'} list (type is fixed once created)
</p>
<div class="sm:col-span-2">
<ListPictureDropzone label="List picture (optional)" />
{#if data.list.pictureUrl}<label class="mt-2 block text-sm"
@@ -238,8 +228,21 @@
>
{item.description}
</p>{/if}
{#if data.list.columns.length}<p class="mt-1 text-sm text-[var(--site-muted)]">
{#each data.list.columns.filter((c) => item.extras[c.name] !== null && item.extras[c.name] !== '') as column, i (column.name)}{#if i}
·
{/if}{column.label}: {#if column.uiType === 'url'}<a
class="underline"
href={String(item.extras[column.name])}
rel="noopener noreferrer">link</a
>{:else if column.uiType === 'bool'}{item.extras[column.name]
? 'yes'
: 'no'}{:else if column.uiType === 'cents'}{(
Number(item.extras[column.name]) / 100
).toFixed(2)}{:else}{item.extras[column.name]}{/if}{/each}
</p>{/if}
</div>
{#if data.list.isProgress && data.user}
{#if data.list.isProgress && data.isAdmin}
<form method="POST" action="?/setCompletion" use:enhance class="shrink-0">
<input type="hidden" name="itemId" value={item.id} />
<input type="hidden" name="completed" value={completed.has(item.id) ? 'no' : 'yes'} />
@@ -251,6 +254,8 @@
{#if completed.has(item.id)}<Check size={16} aria-hidden="true" />{/if}
</Button>
</form>
{:else if data.list.isProgress && completed.has(item.id)}
<Check size={16} aria-label="Done" class="shrink-0" />
{/if}
{#if data.isAdmin && managing}<Button
type="button"
@@ -309,6 +314,7 @@
value={editingItem.description ?? ''}
/></label
>
<ListExtraFields columns={data.list.columns} values={editingItem.extras} />
<Button
type="submit"
disabled={uploading}
@@ -373,6 +379,7 @@
class="block w-full rounded border p-2"
/></label
>
<ListExtraFields columns={data.list.columns} />
<Button
type="submit"
disabled={uploading}

View File

@@ -28,7 +28,7 @@ export const load: PageServerLoad = async (event) => {
sightingAcceptedAt: sightings.acceptedAt,
sightingDeniedAt: sightings.deniedAt,
photoUrl: sql<string>`(
SELECT url FROM photos
SELECT url FROM animaldex.sighting_photos
WHERE sighting_id = ${sightings.id}
ORDER BY sort_order ASC
LIMIT 1
@@ -40,7 +40,7 @@ export const load: PageServerLoad = async (event) => {
and(
eq(sightings.animalId, animals.id),
sql`${sightings.id} = (
SELECT id FROM sightings
SELECT id FROM animaldex.sightings
WHERE animal_id = ${animals.id}
ORDER BY submitted_at ASC
LIMIT 1

View File

@@ -45,7 +45,7 @@ export const GET: RequestHandler = async ({ url }) => {
lat: sightings.lat,
lng: sightings.lng,
photoUrl: sql<string>`(
SELECT url FROM photos
SELECT url FROM animaldex.sighting_photos
WHERE sighting_id = ${sightings.id}
ORDER BY sort_order ASC
LIMIT 1

View File

@@ -5,13 +5,13 @@ import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const [stats, dimensions, markets] = await Promise.all([
db.execute(sql`SELECT
(SELECT count(*)::int FROM cs_market_items) AS items,
(SELECT greatest(reltuples::bigint, 0) FROM pg_class WHERE oid = 'cs_price_observations'::regclass) AS observations,
(SELECT min(observed_at) FROM cs_price_observations) AS earliest,
(SELECT max(observed_at) FROM cs_price_observations) AS latest`),
(SELECT count(*)::int FROM cs.items) AS items,
(SELECT greatest(reltuples::bigint, 0) FROM pg_class WHERE oid = 'cs.price_observations'::regclass) AS observations,
(SELECT min(observed_at) FROM cs.price_observations) AS earliest,
(SELECT max(observed_at) FROM cs.price_observations) AS latest`),
db.execute(sql`SELECT key, count(DISTINCT value)::int AS values
FROM cs_item_dimensions GROUP BY key ORDER BY key`),
db.execute(sql`SELECT source, currency, kind FROM cs_price_streams ORDER BY 1, 2, 3`)
FROM cs.item_dimensions GROUP BY key ORDER BY key`),
db.execute(sql`SELECT source, currency, kind FROM cs.price_streams ORDER BY 1, 2, 3`)
]);
return {
stats: stats[0] as {

View File

@@ -10,14 +10,14 @@ export const GET: RequestHandler = async ({ url }) => {
const pattern = `%${q.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
if (dimension === 'item') {
const rows = await db.execute(sql`SELECT id, name, market_hash_name, icon_url
FROM cs_market_items WHERE name ILIKE ${pattern} ESCAPE '\'
FROM cs.items WHERE name ILIKE ${pattern} ESCAPE '\'
ORDER BY name, id LIMIT ${limit} OFFSET ${offset}`);
return Response.json({ results: rows });
}
if (!/^[a-z][a-z0-9_]*$/.test(dimension))
return Response.json({ error: 'Invalid dimension' }, { status: 400 });
const rows = await db.execute(sql`SELECT value, count(*)::int AS items
FROM cs_item_dimensions WHERE key = ${dimension} AND value ILIKE ${pattern} ESCAPE '\'
FROM cs.item_dimensions WHERE key = ${dimension} AND value ILIKE ${pattern} ESCAPE '\'
GROUP BY value ORDER BY value LIMIT ${limit} OFFSET ${offset}`);
return Response.json({ results: rows });
};

View File

@@ -34,11 +34,11 @@ export const POST: RequestHandler = async ({ request }) => {
const seconds = Math.max(60, Math.ceil((+to - +from) / 1000 / 240 / 60) * 60);
const selection =
dimension === 'item'
? sql`JOIN cs_market_items i ON i.id = p.item_id AND i.id IN (${sql.join(
? sql`JOIN cs.items i ON i.id = p.item_id AND i.id IN (${sql.join(
ids.map((id) => sql`${id}`),
sql`,`
)})`
: sql`JOIN cs_item_dimensions d ON d.item_id = p.item_id AND d.key = ${dimension}
: sql`JOIN cs.item_dimensions d ON d.item_id = p.item_id AND d.key = ${dimension}
AND d.value IN (${sql.join(
values.map((value) => sql`${value}`),
sql`,`
@@ -53,7 +53,7 @@ export const POST: RequestHandler = async ({ request }) => {
sum(p.volume) AS volume,
min(p.price_cents) AS min_cents,
max(p.price_cents) AS max_cents
FROM cs_price_observations p ${selection}
FROM cs.price_observations p ${selection}
WHERE p.source = ${source} AND p.currency = ${currency} AND p.kind = ${kind}
AND p.observed_at >= ${from.toISOString()}::timestamptz
AND p.observed_at <= ${to.toISOString()}::timestamptz

View File

@@ -82,9 +82,9 @@ export const actions = {
duration,
combo,
misses,
accuracy: t('accuracy') || null,
pp: t('pp') || null,
rate: t('rate'),
accuracy: t('accuracy') ? Number(t('accuracy')) : null,
pp: t('pp') ? Number(t('pp')) : null,
rate: Number(t('rate')),
mods: t('mods') || 'NM',
beatmapUrl: t('beatmapUrl') || null,
scoreUrl: t('scoreUrl') || null,

View File

@@ -91,7 +91,7 @@ export const load: PageServerLoad = async (event) => {
.limit(400),
db.execute(sql`SELECT city_slug AS slug, min(city) AS name,
count(*) FILTER (WHERE removed_at IS NULL)::int AS active
FROM house_listings WHERE offer = ${offer} AND city_slug IS NOT NULL
FROM house.listings WHERE offer = ${offer} AND city_slug IS NOT NULL
GROUP BY city_slug ORDER BY active DESC, slug LIMIT 200`),
db.execute(sql`SELECT coalesce(min(city), city_slug) AS city,
count(*) FILTER (WHERE removed_at IS NULL)::int AS active,
@@ -103,18 +103,18 @@ export const load: PageServerLoad = async (event) => {
percentile_cont(0.5) WITHIN GROUP (
ORDER BY extract(epoch FROM removed_at - coalesce(offered_at, first_seen_at)) / 86400)
FILTER (WHERE removed_at > now() - interval '90 days') AS median_days_listed
FROM house_listings WHERE offer = ${offer} AND city_slug IS NOT NULL
FROM house.listings WHERE offer = ${offer} AND city_slug IS NOT NULL
GROUP BY city_slug ORDER BY active DESC LIMIT 30`),
db.select().from(houseAlertRules).orderBy(asc(houseAlertRules.id)),
db.select().from(houseWorkerRuns).orderBy(desc(houseWorkerRuns.createdAt)).limit(25),
db.execute(sql`SELECT
(SELECT max(created_at) FROM house_worker_runs WHERE status = 'ok') AS last_ok,
(SELECT max(created_at) FROM house_worker_runs WHERE status = 'challenged') AS last_challenge,
(SELECT count(*)::int FROM house_listings) AS total,
(SELECT count(*)::int FROM house_listings WHERE alerted_at > now() - interval '7 days') AS alerts_week,
(SELECT max(created_at) FROM house.worker_runs WHERE status = 'ok') AS last_ok,
(SELECT max(created_at) FROM house.worker_runs WHERE status = 'challenged') AS last_challenge,
(SELECT count(*)::int FROM house.listings) AS total,
(SELECT count(*)::int FROM house.listings WHERE alerted_at > now() - interval '7 days') AS alerts_week,
(SELECT percentile_cont(0.5) WITHIN GROUP (
ORDER BY extract(epoch FROM first_seen_at - offered_at) / 60)
FROM house_listings WHERE first_seen_lane = 'fast' AND offered_at IS NOT NULL
FROM house.listings WHERE first_seen_lane = 'fast' AND offered_at IS NOT NULL
AND first_seen_at >= offered_at AND first_seen_at - offered_at < interval '3 days'
AND first_seen_at > now() - interval '30 days')
AS median_detect_minutes`)

View File

@@ -300,7 +300,7 @@ export const actions: Actions = {
fixedMonthlyCents,
annualReturnPercent,
contributionMode,
includeRecurring: form.get('includeRecurring') === 'on' ? 1 : 0,
includeRecurring: form.get('includeRecurring') === 'on',
endDate
};
await db

View File

@@ -324,7 +324,7 @@
</div>
<p class="muted projection-note">
Includes future dated transactions and estimates{data.projectionSettings
?.includeRecurring !== 0
?.includeRecurring !== false
? ', plus recurring monthly equivalents from next month'
: ''}. {data.projectionSettings?.investmentAccountId
? `The selected investment account and new contributions use a hypothetical ${data.projectionSettings.annualReturnPercent}% annual return.`
@@ -991,8 +991,11 @@
></label
>
<label class="field check-field"
><Checkbox name="includeRecurring" value="on" checked={active.includeRecurring !== 0} /> Include
recurring items from next month</label
><Checkbox
name="includeRecurring"
value="on"
checked={active.includeRecurring !== false}
/> Include recurring items from next month</label
>
{:else if mode === 'settings'}
<input type="hidden" name="kind" value={active.kind ?? selectedUtility} />