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

@@ -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} />