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:
@@ -3,9 +3,10 @@ import postgres from 'postgres';
|
|||||||
// Read-only fingerprints: lets a deployment verify saved progress without logging it.
|
// Read-only fingerprints: lets a deployment verify saved progress without logging it.
|
||||||
if (!process.env.DB_URL) throw new Error('DB_URL is required');
|
if (!process.env.DB_URL) throw new Error('DB_URL is required');
|
||||||
const client = postgres(process.env.DB_URL, { max: 1 });
|
const client = postgres(process.env.DB_URL, { max: 1 });
|
||||||
|
// All saved progress lives in schema `games` since the schema reorg.
|
||||||
const tables = [
|
const tables = [
|
||||||
'user_entries',
|
'catch_em_all_entries',
|
||||||
'user_games',
|
'catch_em_all_games',
|
||||||
'catch_em_all_settings',
|
'catch_em_all_settings',
|
||||||
'pokemon_journeys',
|
'pokemon_journeys',
|
||||||
'pokemon_catches',
|
'pokemon_catches',
|
||||||
@@ -20,11 +21,11 @@ const tables = [
|
|||||||
try {
|
try {
|
||||||
const result: Record<string, unknown> = {};
|
const result: Record<string, unknown> = {};
|
||||||
for (const table of tables) {
|
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;
|
if (!exists.present) continue;
|
||||||
const [row] = await client.unsafe(`select count(*)::int as rows,
|
const [row] = await client.unsafe(`select count(*)::int as rows,
|
||||||
md5(coalesce(string_agg(record, E'\\n' order by record), '')) as fingerprint
|
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;
|
result[table] = row;
|
||||||
}
|
}
|
||||||
console.log(JSON.stringify(result, null, 2));
|
console.log(JSON.stringify(result, null, 2));
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ async function itemId(
|
|||||||
extraDimensions: Dimensions = {}
|
extraDimensions: Dimensions = {}
|
||||||
) {
|
) {
|
||||||
const [row] = await sql<{ id: number }[]>`
|
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})
|
VALUES (${name}, ${displayName}, ${iconUrl ?? null}, ${itemType ?? null})
|
||||||
ON CONFLICT (market_hash_name) DO UPDATE SET
|
ON CONFLICT (market_hash_name) DO UPDATE SET
|
||||||
name = EXCLUDED.name,
|
name = EXCLUDED.name,
|
||||||
@@ -98,13 +98,13 @@ async function itemId(
|
|||||||
for (const [key, value] of Object.entries(inferred)) {
|
for (const [key, value] of Object.entries(inferred)) {
|
||||||
if (!/^[a-z][a-z0-9_]*$/.test(key) || !value) continue;
|
if (!/^[a-z][a-z0-9_]*$/.test(key) || !value) continue;
|
||||||
await sql`
|
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`;
|
ON CONFLICT (item_id, key) DO NOTHING`;
|
||||||
}
|
}
|
||||||
for (const [key, value] of Object.entries(extraDimensions)) {
|
for (const [key, value] of Object.entries(extraDimensions)) {
|
||||||
if (!/^[a-z][a-z0-9_]*$/.test(key) || !value) continue;
|
if (!/^[a-z][a-z0-9_]*$/.test(key) || !value) continue;
|
||||||
await sql`
|
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`;
|
ON CONFLICT (item_id, key) DO UPDATE SET value = EXCLUDED.value`;
|
||||||
}
|
}
|
||||||
return row.id;
|
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 itemId(row.marketHashName, row.name, undefined, undefined, row.dimensions));
|
||||||
await ensureStream(row.source, row.currency, row.kind);
|
await ensureStream(row.source, row.currency, row.kind);
|
||||||
await sql`
|
await sql`
|
||||||
INSERT INTO cs_price_observations
|
INSERT INTO cs.price_observations
|
||||||
(item_id, source, currency, observed_at, price_cents, volume, kind)
|
(item_id, source, currency, observed_at, price_cents, volume, kind)
|
||||||
VALUES (${item}, ${row.source}, ${row.currency}, ${new Date(row.observedAt)},
|
VALUES (${item}, ${row.source}, ${row.currency}, ${new Date(row.observedAt)},
|
||||||
${row.priceCents}, ${row.volume ?? null}, ${row.kind})
|
${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) {
|
async function ensureStream(source: string, currency: string, kind: string) {
|
||||||
const key = `${source}\0${currency}\0${kind}`;
|
const key = `${source}\0${currency}\0${kind}`;
|
||||||
if (knownStreams.has(key)) return;
|
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`;
|
VALUES (${source}, ${currency}, ${kind}) ON CONFLICT DO NOTHING`;
|
||||||
knownStreams.add(key);
|
knownStreams.add(key);
|
||||||
}
|
}
|
||||||
@@ -150,7 +150,7 @@ async function saveHistoryRows(
|
|||||||
kind: 'sale-median'
|
kind: 'sale-median'
|
||||||
}));
|
}));
|
||||||
for (let offset = 0; offset < rows.length; offset += 500) {
|
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
|
ON CONFLICT (item_id, source, currency, observed_at, kind) DO UPDATE SET
|
||||||
price_cents = EXCLUDED.price_cents, volume = EXCLUDED.volume`;
|
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)
|
if (!process.env.STEAM_SESSIONID || !process.env.STEAM_LOGIN_SECURE)
|
||||||
throw new Error('STEAM_SESSIONID and STEAM_LOGIN_SECURE are required for Steam history');
|
throw new Error('STEAM_SESSIONID and STEAM_LOGIN_SECURE are required for Steam history');
|
||||||
const items = await sql<{ id: number; market_hash_name: string }[]>`
|
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;
|
let done = 0;
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const url = new URL('https://steamcommunity.com/market/pricehistory/');
|
const url = new URL('https://steamcommunity.com/market/pricehistory/');
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ try {
|
|||||||
await sql.begin(async (tx) => {
|
await sql.begin(async (tx) => {
|
||||||
for (const image of artwork) {
|
for (const image of artwork) {
|
||||||
const rows =
|
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}`);
|
if (rows.length !== 1) throw new Error(`Unknown artwork target: ${image.game}/${image.key}`);
|
||||||
}
|
}
|
||||||
for (const [source, path] of Object.entries(achievementArt)) {
|
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(
|
console.log(
|
||||||
|
|||||||
@@ -22,17 +22,17 @@ try {
|
|||||||
}[]
|
}[]
|
||||||
][]) {
|
][]) {
|
||||||
const [game] =
|
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}`);
|
if (!game) throw new Error(`Seed Call of Duty catalog first: ${slug}`);
|
||||||
for (let offset = 0; offset < rows.length; offset += 500) {
|
for (let offset = 0; offset < rows.length; offset += 500) {
|
||||||
const batch = rows
|
const batch = rows
|
||||||
.slice(offset, offset + 500)
|
.slice(offset, offset + 500)
|
||||||
.map(({ sourceUrl, ...r }) => ({ ...r, game_id: game.id, source_url: sourceUrl }));
|
.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.
|
// Retire removed source definitions only when nobody has saved progress.
|
||||||
if (rows.length)
|
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`);
|
console.log(`${slug}: ${rows.length} challenge rows`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ const client = postgres(process.env.DB_URL, { max: 1 });
|
|||||||
try {
|
try {
|
||||||
await client.begin(async (tx) => {
|
await client.begin(async (tx) => {
|
||||||
const species = new Map(
|
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(
|
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 speciesId = (n: number) => {
|
||||||
const id = species.get(n);
|
const id = species.get(n);
|
||||||
@@ -25,10 +25,10 @@ try {
|
|||||||
return id;
|
return id;
|
||||||
};
|
};
|
||||||
for (const row of catalog.facts)
|
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) {
|
for (const row of catalog.games) {
|
||||||
if (!games.has(row.sourceId)) continue;
|
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) {
|
for (let offset = 0; offset < catalog.dexes.length; offset += 1000) {
|
||||||
const batch = catalog.dexes
|
const batch = catalog.dexes
|
||||||
@@ -42,10 +42,10 @@ try {
|
|||||||
number
|
number
|
||||||
}));
|
}));
|
||||||
if (batch.length)
|
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)
|
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) {
|
for (let offset = 0; offset < catalog.learnsets.length; offset += 500) {
|
||||||
const batch = catalog.learnsets
|
const batch = catalog.learnsets
|
||||||
.slice(offset, offset + 500)
|
.slice(offset, offset + 500)
|
||||||
@@ -54,7 +54,7 @@ try {
|
|||||||
version_group,
|
version_group,
|
||||||
moves: tx.json(moves)
|
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(
|
console.log(
|
||||||
`Seeded ${catalog.facts.length} profiles, ${catalog.dexes.length} dex entries, ${catalog.moves.length} moves and ${catalog.learnsets.length} versioned learnsets`
|
`Seeded ${catalog.facts.length} profiles, ${catalog.dexes.length} dex entries, ${catalog.moves.length} moves and ${catalog.learnsets.length} versioned learnsets`
|
||||||
|
|||||||
@@ -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 });
|
const sql = postgres(process.env.DB_URL, { max: 1 });
|
||||||
try {
|
try {
|
||||||
const users =
|
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)
|
if (!users.length)
|
||||||
throw new Error('User ID not found. Register first and copy the ID from your account page.');
|
throw new Error('User ID not found. Register first and copy the ID from your account page.');
|
||||||
console.log(users[0]);
|
console.log(users[0]);
|
||||||
|
|||||||
59
src/lib/components/ListExtraFields.svelte
Normal file
59
src/lib/components/ListExtraFields.svelte
Normal 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}
|
||||||
@@ -94,7 +94,7 @@ describe('money tracker calculations', () => {
|
|||||||
annualReturnPercent: '5.00',
|
annualReturnPercent: '5.00',
|
||||||
contributionMode: 'excess',
|
contributionMode: 'excess',
|
||||||
fixedMonthlyCents: 0,
|
fixedMonthlyCents: 0,
|
||||||
includeRecurring: 1,
|
includeRecurring: true,
|
||||||
endDate: '2026-11-30'
|
endDate: '2026-11-30'
|
||||||
};
|
};
|
||||||
const accounts = [
|
const accounts = [
|
||||||
@@ -140,7 +140,7 @@ describe('money tracker calculations', () => {
|
|||||||
annualReturnPercent: '5.00',
|
annualReturnPercent: '5.00',
|
||||||
contributionMode: 'excess',
|
contributionMode: 'excess',
|
||||||
fixedMonthlyCents: 0,
|
fixedMonthlyCents: 0,
|
||||||
includeRecurring: 1,
|
includeRecurring: true,
|
||||||
endDate: '2026-11-30'
|
endDate: '2026-11-30'
|
||||||
},
|
},
|
||||||
'2026-09-23'
|
'2026-09-23'
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ type ProjectionSettings = {
|
|||||||
annualReturnPercent: string | number;
|
annualReturnPercent: string | number;
|
||||||
contributionMode: string;
|
contributionMode: string;
|
||||||
fixedMonthlyCents: number;
|
fixedMonthlyCents: number;
|
||||||
includeRecurring: number;
|
includeRecurring: boolean;
|
||||||
endDate?: string | null;
|
endDate?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -160,7 +160,7 @@ export function wealthProjection(
|
|||||||
if (entry.accountId === validInvestmentId) indexBalance += entry.amountCents;
|
if (entry.accountId === validInvestmentId) indexBalance += entry.amountCents;
|
||||||
} else spendable += entry.amountCents;
|
} else spendable += entry.amountCents;
|
||||||
}
|
}
|
||||||
if (settings?.includeRecurring !== 0 && index > 0) {
|
if (settings?.includeRecurring !== false && index > 0) {
|
||||||
for (const item of recurring) {
|
for (const item of recurring) {
|
||||||
if ((item.startsOn && item.startsOn > date) || (item.endsOn && item.endsOn < first))
|
if ((item.startsOn && item.startsOn > date) || (item.endsOn && item.endsOn < first))
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
296
src/lib/server/lists.ts
Normal file
296
src/lib/server/lists.ts
Normal 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})`);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { db } from '#lib/server/db/index.js';
|
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';
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
export function slugify(title: string, fallback = 'article'): string {
|
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');
|
const base = slugify(name, 'list');
|
||||||
let slug = base;
|
let slug = base;
|
||||||
let suffix = 2;
|
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++}`;
|
slug = `${base}-${suffix++}`;
|
||||||
}
|
}
|
||||||
return slug;
|
return slug;
|
||||||
|
|||||||
@@ -1,33 +1,10 @@
|
|||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import { db } from '#lib/server/db/index.js';
|
import { listSummaries } from '#lib/server/lists.js';
|
||||||
import { listItemCompletions } from '#lib/server/db/schema/index.js';
|
|
||||||
import { eq } from 'drizzle-orm';
|
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
export const load: PageServerLoad = async () => {
|
||||||
const lists = await db.query.lists.findMany({
|
// `done` is a property of the item now (one owner), so progress is the same for every visitor.
|
||||||
columns: { id: true, name: true, slug: true, isProgress: true, pictureUrl: true },
|
return { lists: await listSummaries() };
|
||||||
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
|
|
||||||
}))
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Retire the old shared-token login; legacy cookies grant no permissions.
|
// Retire the old shared-token login; legacy cookies grant no permissions.
|
||||||
|
|||||||
@@ -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 { requireAdmin } from '#lib/server/authorization.js';
|
||||||
import { uniqueListSlug } from '#lib/server/slugify.js';
|
import { uniqueListSlug } from '#lib/server/slugify.js';
|
||||||
import { listImageUrl } from '#lib/server/r2.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 { fail, redirect } from '@sveltejs/kit';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
export const load: PageServerLoad = async () => ({ lists: await listSummaries() });
|
||||||
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 actions: Actions = {
|
export const actions: Actions = {
|
||||||
newList: async (event) => {
|
newList: async (event) => {
|
||||||
@@ -47,8 +23,9 @@ export const actions: Actions = {
|
|||||||
return fail(400, { message: 'Enable JavaScript to upload an image.' });
|
return fail(400, { message: 'Enable JavaScript to upload an image.' });
|
||||||
const pictureUrl = pictureKey ? listImageUrl(pictureKey) : null;
|
const pictureUrl = pictureKey ? listImageUrl(pictureKey) : null;
|
||||||
if (pictureKey && !pictureUrl) return fail(400, { message: 'Invalid uploaded picture.' });
|
if (pictureKey && !pictureUrl) return fail(400, { message: 'Invalid uploaded picture.' });
|
||||||
const slug = await uniqueListSlug(name);
|
// meta.tables.slug allows at most 63 characters
|
||||||
await db.insert(lists).values({ name, slug, isProgress: type === 'progress', pictureUrl });
|
const slug = (await uniqueListSlug(name.slice(0, 60))).slice(0, 63).replace(/-+$/, '');
|
||||||
|
await createList({ slug, name, isProgress: type === 'progress', pictureUrl });
|
||||||
redirect(303, `/lists/${slug}`);
|
redirect(303, `/lists/${slug}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -123,7 +123,7 @@
|
|||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="min-w-16 whitespace-nowrap text-right text-base text-[var(--site-muted)]">
|
<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}%
|
{list.total ? Math.round((list.checked / list.total) * 100) : 0}%
|
||||||
{:else}
|
{:else}
|
||||||
{list.total} {list.total === 1 ? 'item' : 'items'}
|
{list.total} {list.total === 1 ? 'item' : 'items'}
|
||||||
|
|||||||
@@ -1,15 +1,28 @@
|
|||||||
import { db } from '#lib/server/db/index.js';
|
import { db } from '#lib/server/db/index.js';
|
||||||
import { ORIGIN } from '$app/env/private';
|
import { ORIGIN } from '$app/env/private';
|
||||||
import { lists, listItems, listItemCompletions } from '#lib/server/db/schema/index.js';
|
import { metaTables } from '#lib/server/db/schema/index.js';
|
||||||
import { requireAdmin, requireUser } from '#lib/server/authorization.js';
|
import { requireAdmin } from '#lib/server/authorization.js';
|
||||||
import { deleteObject, listImageKey, listImageUrl } from '#lib/server/r2.js';
|
import { deleteObject, listImageKey, listImageUrl } from '#lib/server/r2.js';
|
||||||
import { validSlug } from '#lib/server/slugify.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 { and, eq, ne } from 'drizzle-orm';
|
||||||
import { error, fail, redirect } from '@sveltejs/kit';
|
import { error, fail, redirect } from '@sveltejs/kit';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
async function currentList(slug: string) {
|
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');
|
if (!list) error(404, 'List not found');
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
@@ -24,7 +37,7 @@ async function removeStoredPicture(url: string | null) {
|
|||||||
console.error('Could not remove list picture from R2', cause);
|
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 name = String(form.get('name') ?? '').trim();
|
||||||
const description = String(form.get('description') ?? '').trim();
|
const description = String(form.get('description') ?? '').trim();
|
||||||
const pictureKey = String(form.get('pictureKey') ?? '').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.' };
|
if (pictureKey && removePicture) return { error: 'Choose a new picture or remove the old one.' };
|
||||||
const pictureUrl = pictureKey ? listImageUrl(pictureKey) : null;
|
const pictureUrl = pictureKey ? listImageUrl(pictureKey) : null;
|
||||||
if (pictureKey && !pictureUrl) return { error: 'Invalid uploaded picture.' };
|
if (pictureKey && !pictureUrl) return { error: 'Invalid uploaded picture.' };
|
||||||
|
const extras = extraFields(form, columns);
|
||||||
|
if ('error' in extras) return { error: extras.error };
|
||||||
return {
|
return {
|
||||||
values: {
|
values: {
|
||||||
name,
|
name,
|
||||||
description: description || null,
|
description: description || null,
|
||||||
|
extras: extras.values,
|
||||||
...(pictureKey ? { pictureUrl } : removePicture ? { pictureUrl: null } : {})
|
...(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)) {
|
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);
|
const oldId = Number(params.slug);
|
||||||
if (Number.isSafeInteger(oldId)) {
|
if (Number.isSafeInteger(oldId)) {
|
||||||
const oldList = await db.query.lists.findFirst({ where: { id: oldId } });
|
const [old] = await db
|
||||||
if (oldList) redirect(308, `/lists/${oldList.slug}`);
|
.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({
|
const list = await getList(params.slug);
|
||||||
where: { slug: params.slug },
|
|
||||||
with: { items: { orderBy: { id: 'asc' } } }
|
|
||||||
});
|
|
||||||
if (!list) error(404, 'List not found');
|
if (!list) error(404, 'List not found');
|
||||||
const completedIds =
|
const completedIds = list.isProgress
|
||||||
locals.user && list.isProgress
|
? list.items.filter((item) => item.doneAt).map((item) => item.id)
|
||||||
? (
|
: [];
|
||||||
await db
|
return { list, completedIds, checked: completedIds.length, total: list.items.length };
|
||||||
.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 };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
deleteList: async (event) => {
|
deleteList: async (event) => {
|
||||||
requireAdmin(event);
|
requireAdmin(event);
|
||||||
const list = await currentList(event.params.slug);
|
const list = await currentList(event.params.slug);
|
||||||
const confirmation = String((await event.request.formData()).get('confirmName') ?? '');
|
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.' });
|
return fail(400, { message: 'Type the exact list name to confirm deletion.' });
|
||||||
const pictures = await db
|
// Archived, not dropped: the table and its pictures stay restorable for 7 days.
|
||||||
.select({ pictureUrl: listItems.pictureUrl })
|
await archiveList(list.slug);
|
||||||
.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)
|
|
||||||
);
|
|
||||||
redirect(303, '/lists');
|
redirect(303, '/lists');
|
||||||
},
|
},
|
||||||
updateList: async (event) => {
|
updateList: async (event) => {
|
||||||
@@ -94,15 +104,12 @@ export const actions: Actions = {
|
|||||||
const slug = String(form.get('slug') ?? '')
|
const slug = String(form.get('slug') ?? '')
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
const type = String(form.get('type') ?? '');
|
|
||||||
const pictureKey = String(form.get('pictureKey') ?? '').trim();
|
const pictureKey = String(form.get('pictureKey') ?? '').trim();
|
||||||
const removePicture = form.get('removePicture') === 'on';
|
const removePicture = form.get('removePicture') === 'on';
|
||||||
const picture = form.get('picture');
|
const picture = form.get('picture');
|
||||||
if (!name || name.length > 200)
|
if (!name || name.length > 200)
|
||||||
return fail(400, { message: 'Enter a name of 1–200 characters' });
|
return fail(400, { message: 'Enter a name of 1–200 characters' });
|
||||||
if (type !== 'normal' && type !== 'progress')
|
if (!validSlug(slug) || slug.length > 63)
|
||||||
return fail(400, { message: 'Choose a list type.' });
|
|
||||||
if (!validSlug(slug))
|
|
||||||
return fail(400, { message: 'Use a lowercase slug with letters, numbers, and hyphens.' });
|
return fail(400, { message: 'Use a lowercase slug with letters, numbers, and hyphens.' });
|
||||||
if (picture instanceof File && picture.size > 0)
|
if (picture instanceof File && picture.size > 0)
|
||||||
return fail(400, { message: 'Enable JavaScript to upload an image.' });
|
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.' });
|
if (pictureKey && !pictureUrl) return fail(400, { message: 'Invalid uploaded picture.' });
|
||||||
const current = await currentList(event.params.slug);
|
const current = await currentList(event.params.slug);
|
||||||
const duplicate = await db
|
const duplicate = await db
|
||||||
.select({ id: lists.id })
|
.select({ id: metaTables.id })
|
||||||
.from(lists)
|
.from(metaTables)
|
||||||
.where(and(eq(lists.slug, slug), ne(lists.id, current.id)));
|
.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.' });
|
if (duplicate.length) return fail(409, { message: 'That list slug is already in use.' });
|
||||||
const [list] = await db
|
const [list] = await db
|
||||||
.update(lists)
|
.update(metaTables)
|
||||||
.set({
|
.set({
|
||||||
name,
|
displayName: name,
|
||||||
slug,
|
slug,
|
||||||
isProgress: type === 'progress',
|
|
||||||
...(pictureKey ? { pictureUrl } : removePicture ? { pictureUrl: null } : {})
|
...(pictureKey ? { pictureUrl } : removePicture ? { pictureUrl: null } : {})
|
||||||
})
|
})
|
||||||
.where(eq(lists.id, current.id))
|
.where(eq(metaTables.id, current.id))
|
||||||
.returning();
|
.returning({ pictureUrl: metaTables.pictureUrl });
|
||||||
if (!list) error(404, 'List not found');
|
if (!list) error(404, 'List not found');
|
||||||
if (list.pictureUrl !== current.pictureUrl) await removeStoredPicture(current.pictureUrl);
|
if (list.pictureUrl !== current.pictureUrl) await removeStoredPicture(current.pictureUrl);
|
||||||
if (slug !== event.params.slug) redirect(303, `/lists/${slug}`);
|
if (slug !== event.params.slug) redirect(303, `/lists/${slug}`);
|
||||||
@@ -133,66 +139,48 @@ export const actions: Actions = {
|
|||||||
},
|
},
|
||||||
addItem: async (event) => {
|
addItem: async (event) => {
|
||||||
requireAdmin(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);
|
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 };
|
return { saved: true };
|
||||||
},
|
},
|
||||||
updateItem: async (event) => {
|
updateItem: async (event) => {
|
||||||
requireAdmin(event);
|
requireAdmin(event);
|
||||||
const form = await event.request.formData();
|
const form = await event.request.formData();
|
||||||
const id = Number(form.get('itemId'));
|
const id = itemId(form);
|
||||||
if (!Number.isSafeInteger(id) || id <= 0) return fail(400, { message: 'Invalid item.' });
|
if (!id) return fail(400, { message: 'Invalid item.' });
|
||||||
const fields = itemFields(form);
|
|
||||||
if (fields.error) return fail(400, { message: fields.error });
|
|
||||||
const list = await currentList(event.params.slug);
|
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');
|
if (!existing) error(404, 'Item not found');
|
||||||
const [item] = await db
|
if (!(await updateItem(list, id, fields.values!))) error(404, 'Item not found');
|
||||||
.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 ('pictureUrl' in fields.values! && fields.values!.pictureUrl !== existing.pictureUrl)
|
if ('pictureUrl' in fields.values! && fields.values!.pictureUrl !== existing.pictureUrl)
|
||||||
await removeStoredPicture(existing.pictureUrl);
|
await removeStoredPicture(existing.pictureUrl);
|
||||||
return { saved: true };
|
return { saved: true };
|
||||||
},
|
},
|
||||||
removeItem: async (event) => {
|
removeItem: async (event) => {
|
||||||
requireAdmin(event);
|
requireAdmin(event);
|
||||||
const id = Number((await event.request.formData()).get('itemId'));
|
const id = itemId(await event.request.formData());
|
||||||
if (!Number.isSafeInteger(id) || id <= 0) return fail(400, { message: 'Invalid item' });
|
if (!id) return fail(400, { message: 'Invalid item' });
|
||||||
const list = await currentList(event.params.slug);
|
const list = await currentList(event.params.slug);
|
||||||
const [item] = await db
|
const item = await deleteItem(list, id);
|
||||||
.delete(listItems)
|
|
||||||
.where(and(eq(listItems.id, id), eq(listItems.listId, list.id)))
|
|
||||||
.returning({ pictureUrl: listItems.pictureUrl });
|
|
||||||
if (!item) error(404, 'Item not found');
|
if (!item) error(404, 'Item not found');
|
||||||
await removeStoredPicture(item.pictureUrl);
|
await removeStoredPicture(item.picture_url);
|
||||||
return { saved: true };
|
return { saved: true };
|
||||||
},
|
},
|
||||||
setCompletion: async (event) => {
|
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);
|
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 form = await event.request.formData();
|
||||||
const id = Number(form.get('itemId'));
|
const id = itemId(form);
|
||||||
const completed = form.get('completed');
|
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.' });
|
return fail(400, { message: 'Invalid progress change.' });
|
||||||
const item = await db.query.listItems.findFirst({ where: { id, listId: list.id } });
|
if (!(await setDone(list, id, completed === 'yes'))) error(404, 'Item not found');
|
||||||
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)));
|
|
||||||
}
|
|
||||||
return { saved: true };
|
return { saved: true };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
|
|
||||||
import { Input } from '#lib/components/ui/input/index.js';
|
import { Input } from '#lib/components/ui/input/index.js';
|
||||||
import { Textarea } from '#lib/components/ui/textarea/index.js';
|
import { Textarea } from '#lib/components/ui/textarea/index.js';
|
||||||
import { Checkbox } from '#lib/components/ui/checkbox/index.js';
|
import { Checkbox } from '#lib/components/ui/checkbox/index.js';
|
||||||
@@ -14,6 +13,7 @@
|
|||||||
import { enhance, type SubmitFunction } from '$app/forms';
|
import { enhance, type SubmitFunction } from '$app/forms';
|
||||||
import ListPictureDropzone from '#lib/components/ListPictureDropzone.svelte';
|
import ListPictureDropzone from '#lib/components/ListPictureDropzone.svelte';
|
||||||
import ProgressBar from '#lib/components/ProgressBar.svelte';
|
import ProgressBar from '#lib/components/ProgressBar.svelte';
|
||||||
|
import ListExtraFields from '#lib/components/ListExtraFields.svelte';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
let { data, form }: PageProps = $props();
|
let { data, form }: PageProps = $props();
|
||||||
let completed = $derived(new Set(data.completedIds));
|
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 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 uploadError}<p role="alert" class="text-red-700">{uploadError}</p>{/if}
|
||||||
{#if data.list.isProgress}
|
{#if data.list.isProgress}
|
||||||
{#if data.user}
|
<div class="border-y border-[var(--site-border)] py-4">
|
||||||
<div class="border-y border-[var(--site-border)] py-4">
|
<p class="font-medium">
|
||||||
<p class="font-medium">
|
{data.checked} of {data.total} checked · {data.total
|
||||||
{data.checked} of {data.total} checked · {data.total
|
? Math.round((data.checked / data.total) * 100)
|
||||||
? Math.round((data.checked / data.total) * 100)
|
: 0}%
|
||||||
: 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.
|
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
<div class="mt-2">
|
||||||
|
<ProgressBar value={data.checked} max={data.total} label="List progress" small />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if data.isAdmin && managing}
|
{#if data.isAdmin && managing}
|
||||||
<Collapsible.Root class="border-b border-[var(--site-border)] pb-5">
|
<Collapsible.Root class="border-b border-[var(--site-border)] pb-5">
|
||||||
@@ -159,12 +152,9 @@
|
|||||||
class="block w-full rounded border p-2"
|
class="block w-full rounded border p-2"
|
||||||
/></label
|
/></label
|
||||||
>
|
>
|
||||||
<label
|
<p class="self-end text-sm text-[var(--site-muted)]">
|
||||||
>Type<NativeSelect name="type" class="block w-full rounded border p-2">
|
{data.list.isProgress ? 'Progress' : 'Normal'} list (type is fixed once created)
|
||||||
<option value="normal" selected={!data.list.isProgress}>Normal</option>
|
</p>
|
||||||
<option value="progress" selected={data.list.isProgress}>Progress</option>
|
|
||||||
</NativeSelect></label
|
|
||||||
>
|
|
||||||
<div class="sm:col-span-2">
|
<div class="sm:col-span-2">
|
||||||
<ListPictureDropzone label="List picture (optional)" />
|
<ListPictureDropzone label="List picture (optional)" />
|
||||||
{#if data.list.pictureUrl}<label class="mt-2 block text-sm"
|
{#if data.list.pictureUrl}<label class="mt-2 block text-sm"
|
||||||
@@ -238,8 +228,21 @@
|
|||||||
>
|
>
|
||||||
{item.description}
|
{item.description}
|
||||||
</p>{/if}
|
</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>
|
</div>
|
||||||
{#if data.list.isProgress && data.user}
|
{#if data.list.isProgress && data.isAdmin}
|
||||||
<form method="POST" action="?/setCompletion" use:enhance class="shrink-0">
|
<form method="POST" action="?/setCompletion" use:enhance class="shrink-0">
|
||||||
<input type="hidden" name="itemId" value={item.id} />
|
<input type="hidden" name="itemId" value={item.id} />
|
||||||
<input type="hidden" name="completed" value={completed.has(item.id) ? 'no' : 'yes'} />
|
<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}
|
{#if completed.has(item.id)}<Check size={16} aria-hidden="true" />{/if}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
{:else if data.list.isProgress && completed.has(item.id)}
|
||||||
|
<Check size={16} aria-label="Done" class="shrink-0" />
|
||||||
{/if}
|
{/if}
|
||||||
{#if data.isAdmin && managing}<Button
|
{#if data.isAdmin && managing}<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -309,6 +314,7 @@
|
|||||||
value={editingItem.description ?? ''}
|
value={editingItem.description ?? ''}
|
||||||
/></label
|
/></label
|
||||||
>
|
>
|
||||||
|
<ListExtraFields columns={data.list.columns} values={editingItem.extras} />
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
@@ -373,6 +379,7 @@
|
|||||||
class="block w-full rounded border p-2"
|
class="block w-full rounded border p-2"
|
||||||
/></label
|
/></label
|
||||||
>
|
>
|
||||||
|
<ListExtraFields columns={data.list.columns} />
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export const load: PageServerLoad = async (event) => {
|
|||||||
sightingAcceptedAt: sightings.acceptedAt,
|
sightingAcceptedAt: sightings.acceptedAt,
|
||||||
sightingDeniedAt: sightings.deniedAt,
|
sightingDeniedAt: sightings.deniedAt,
|
||||||
photoUrl: sql<string>`(
|
photoUrl: sql<string>`(
|
||||||
SELECT url FROM photos
|
SELECT url FROM animaldex.sighting_photos
|
||||||
WHERE sighting_id = ${sightings.id}
|
WHERE sighting_id = ${sightings.id}
|
||||||
ORDER BY sort_order ASC
|
ORDER BY sort_order ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
@@ -40,7 +40,7 @@ export const load: PageServerLoad = async (event) => {
|
|||||||
and(
|
and(
|
||||||
eq(sightings.animalId, animals.id),
|
eq(sightings.animalId, animals.id),
|
||||||
sql`${sightings.id} = (
|
sql`${sightings.id} = (
|
||||||
SELECT id FROM sightings
|
SELECT id FROM animaldex.sightings
|
||||||
WHERE animal_id = ${animals.id}
|
WHERE animal_id = ${animals.id}
|
||||||
ORDER BY submitted_at ASC
|
ORDER BY submitted_at ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export const GET: RequestHandler = async ({ url }) => {
|
|||||||
lat: sightings.lat,
|
lat: sightings.lat,
|
||||||
lng: sightings.lng,
|
lng: sightings.lng,
|
||||||
photoUrl: sql<string>`(
|
photoUrl: sql<string>`(
|
||||||
SELECT url FROM photos
|
SELECT url FROM animaldex.sighting_photos
|
||||||
WHERE sighting_id = ${sightings.id}
|
WHERE sighting_id = ${sightings.id}
|
||||||
ORDER BY sort_order ASC
|
ORDER BY sort_order ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ import type { PageServerLoad } from './$types';
|
|||||||
export const load: PageServerLoad = async () => {
|
export const load: PageServerLoad = async () => {
|
||||||
const [stats, dimensions, markets] = await Promise.all([
|
const [stats, dimensions, markets] = await Promise.all([
|
||||||
db.execute(sql`SELECT
|
db.execute(sql`SELECT
|
||||||
(SELECT count(*)::int FROM cs_market_items) AS items,
|
(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 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 min(observed_at) FROM cs.price_observations) AS earliest,
|
||||||
(SELECT max(observed_at) FROM cs_price_observations) AS latest`),
|
(SELECT max(observed_at) FROM cs.price_observations) AS latest`),
|
||||||
db.execute(sql`SELECT key, count(DISTINCT value)::int AS values
|
db.execute(sql`SELECT key, count(DISTINCT value)::int AS values
|
||||||
FROM cs_item_dimensions GROUP BY key ORDER BY key`),
|
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`)
|
db.execute(sql`SELECT source, currency, kind FROM cs.price_streams ORDER BY 1, 2, 3`)
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
stats: stats[0] as {
|
stats: stats[0] as {
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ export const GET: RequestHandler = async ({ url }) => {
|
|||||||
const pattern = `%${q.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
|
const pattern = `%${q.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
|
||||||
if (dimension === 'item') {
|
if (dimension === 'item') {
|
||||||
const rows = await db.execute(sql`SELECT id, name, market_hash_name, icon_url
|
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}`);
|
ORDER BY name, id LIMIT ${limit} OFFSET ${offset}`);
|
||||||
return Response.json({ results: rows });
|
return Response.json({ results: rows });
|
||||||
}
|
}
|
||||||
if (!/^[a-z][a-z0-9_]*$/.test(dimension))
|
if (!/^[a-z][a-z0-9_]*$/.test(dimension))
|
||||||
return Response.json({ error: 'Invalid dimension' }, { status: 400 });
|
return Response.json({ error: 'Invalid dimension' }, { status: 400 });
|
||||||
const rows = await db.execute(sql`SELECT value, count(*)::int AS items
|
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}`);
|
GROUP BY value ORDER BY value LIMIT ${limit} OFFSET ${offset}`);
|
||||||
return Response.json({ results: rows });
|
return Response.json({ results: rows });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,11 +34,11 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
const seconds = Math.max(60, Math.ceil((+to - +from) / 1000 / 240 / 60) * 60);
|
const seconds = Math.max(60, Math.ceil((+to - +from) / 1000 / 240 / 60) * 60);
|
||||||
const selection =
|
const selection =
|
||||||
dimension === 'item'
|
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}`),
|
ids.map((id) => sql`${id}`),
|
||||||
sql`,`
|
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(
|
AND d.value IN (${sql.join(
|
||||||
values.map((value) => sql`${value}`),
|
values.map((value) => sql`${value}`),
|
||||||
sql`,`
|
sql`,`
|
||||||
@@ -53,7 +53,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
sum(p.volume) AS volume,
|
sum(p.volume) AS volume,
|
||||||
min(p.price_cents) AS min_cents,
|
min(p.price_cents) AS min_cents,
|
||||||
max(p.price_cents) AS max_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}
|
WHERE p.source = ${source} AND p.currency = ${currency} AND p.kind = ${kind}
|
||||||
AND p.observed_at >= ${from.toISOString()}::timestamptz
|
AND p.observed_at >= ${from.toISOString()}::timestamptz
|
||||||
AND p.observed_at <= ${to.toISOString()}::timestamptz
|
AND p.observed_at <= ${to.toISOString()}::timestamptz
|
||||||
|
|||||||
@@ -82,9 +82,9 @@ export const actions = {
|
|||||||
duration,
|
duration,
|
||||||
combo,
|
combo,
|
||||||
misses,
|
misses,
|
||||||
accuracy: t('accuracy') || null,
|
accuracy: t('accuracy') ? Number(t('accuracy')) : null,
|
||||||
pp: t('pp') || null,
|
pp: t('pp') ? Number(t('pp')) : null,
|
||||||
rate: t('rate'),
|
rate: Number(t('rate')),
|
||||||
mods: t('mods') || 'NM',
|
mods: t('mods') || 'NM',
|
||||||
beatmapUrl: t('beatmapUrl') || null,
|
beatmapUrl: t('beatmapUrl') || null,
|
||||||
scoreUrl: t('scoreUrl') || null,
|
scoreUrl: t('scoreUrl') || null,
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export const load: PageServerLoad = async (event) => {
|
|||||||
.limit(400),
|
.limit(400),
|
||||||
db.execute(sql`SELECT city_slug AS slug, min(city) AS name,
|
db.execute(sql`SELECT city_slug AS slug, min(city) AS name,
|
||||||
count(*) FILTER (WHERE removed_at IS NULL)::int AS active
|
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`),
|
GROUP BY city_slug ORDER BY active DESC, slug LIMIT 200`),
|
||||||
db.execute(sql`SELECT coalesce(min(city), city_slug) AS city,
|
db.execute(sql`SELECT coalesce(min(city), city_slug) AS city,
|
||||||
count(*) FILTER (WHERE removed_at IS NULL)::int AS active,
|
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 (
|
percentile_cont(0.5) WITHIN GROUP (
|
||||||
ORDER BY extract(epoch FROM removed_at - coalesce(offered_at, first_seen_at)) / 86400)
|
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
|
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`),
|
GROUP BY city_slug ORDER BY active DESC LIMIT 30`),
|
||||||
db.select().from(houseAlertRules).orderBy(asc(houseAlertRules.id)),
|
db.select().from(houseAlertRules).orderBy(asc(houseAlertRules.id)),
|
||||||
db.select().from(houseWorkerRuns).orderBy(desc(houseWorkerRuns.createdAt)).limit(25),
|
db.select().from(houseWorkerRuns).orderBy(desc(houseWorkerRuns.createdAt)).limit(25),
|
||||||
db.execute(sql`SELECT
|
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 = 'ok') AS last_ok,
|
||||||
(SELECT max(created_at) FROM house_worker_runs WHERE status = 'challenged') AS last_challenge,
|
(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) AS total,
|
||||||
(SELECT count(*)::int FROM house_listings WHERE alerted_at > now() - interval '7 days') AS alerts_week,
|
(SELECT count(*)::int FROM house.listings WHERE alerted_at > now() - interval '7 days') AS alerts_week,
|
||||||
(SELECT percentile_cont(0.5) WITHIN GROUP (
|
(SELECT percentile_cont(0.5) WITHIN GROUP (
|
||||||
ORDER BY extract(epoch FROM first_seen_at - offered_at) / 60)
|
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 >= offered_at AND first_seen_at - offered_at < interval '3 days'
|
||||||
AND first_seen_at > now() - interval '30 days')
|
AND first_seen_at > now() - interval '30 days')
|
||||||
AS median_detect_minutes`)
|
AS median_detect_minutes`)
|
||||||
|
|||||||
@@ -300,7 +300,7 @@ export const actions: Actions = {
|
|||||||
fixedMonthlyCents,
|
fixedMonthlyCents,
|
||||||
annualReturnPercent,
|
annualReturnPercent,
|
||||||
contributionMode,
|
contributionMode,
|
||||||
includeRecurring: form.get('includeRecurring') === 'on' ? 1 : 0,
|
includeRecurring: form.get('includeRecurring') === 'on',
|
||||||
endDate
|
endDate
|
||||||
};
|
};
|
||||||
await db
|
await db
|
||||||
|
|||||||
@@ -324,7 +324,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="muted projection-note">
|
<p class="muted projection-note">
|
||||||
Includes future dated transactions and estimates{data.projectionSettings
|
Includes future dated transactions and estimates{data.projectionSettings
|
||||||
?.includeRecurring !== 0
|
?.includeRecurring !== false
|
||||||
? ', plus recurring monthly equivalents from next month'
|
? ', plus recurring monthly equivalents from next month'
|
||||||
: ''}. {data.projectionSettings?.investmentAccountId
|
: ''}. {data.projectionSettings?.investmentAccountId
|
||||||
? `The selected investment account and new contributions use a hypothetical ${data.projectionSettings.annualReturnPercent}% annual return.`
|
? `The selected investment account and new contributions use a hypothetical ${data.projectionSettings.annualReturnPercent}% annual return.`
|
||||||
@@ -991,8 +991,11 @@
|
|||||||
></label
|
></label
|
||||||
>
|
>
|
||||||
<label class="field check-field"
|
<label class="field check-field"
|
||||||
><Checkbox name="includeRecurring" value="on" checked={active.includeRecurring !== 0} /> Include
|
><Checkbox
|
||||||
recurring items from next month</label
|
name="includeRecurring"
|
||||||
|
value="on"
|
||||||
|
checked={active.includeRecurring !== false}
|
||||||
|
/> Include recurring items from next month</label
|
||||||
>
|
>
|
||||||
{:else if mode === 'settings'}
|
{:else if mode === 'settings'}
|
||||||
<input type="hidden" name="kind" value={active.kind ?? selectedUtility} />
|
<input type="hidden" name="kind" value={active.kind ?? selectedUtility} />
|
||||||
|
|||||||
Reference in New Issue
Block a user