From 81d0e8854297f74ca05ca7dd2069115d0631e255 Mon Sep 17 00:00:00 2001 From: Axel Paul Gunnarsson <2318558+axelpaul@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:25:22 +0000 Subject: [PATCH] feat: add slot lookup and write confirmations --- README.md | 1 + src/api.ts | 55 +++++++++++++++++++++++ src/commands/slots.ts | 102 ++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 73 ++++++++++++++++++++++++++++++ 4 files changed, 231 insertions(+) create mode 100644 src/commands/slots.ts diff --git a/README.md b/README.md index 314164f..76d924d 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ kronan cart update --quantity N Update one line (qty 0 removes) kronan cart update '' Bulk update via JSON patch kronan cart remove Remove a single line kronan cart clear Clear cart +kronan slots View delivery slots for the default address (set/update/remove/clear support --dry-run) kronan orders Order history diff --git a/src/api.ts b/src/api.ts index 1727271..43ba426 100644 --- a/src/api.ts +++ b/src/api.ts @@ -268,6 +268,31 @@ export interface PaginatedPublicProductPurchaseStatsListResponse { results: PublicProductPurchaseStatsListResponse[]; } +export interface PublicAddress { + id: number; + streetAddress1: string; + city: string; + postalCode: string; + comment: string; + lat: number | null; + lng: number | null; + dropoffOutside: boolean; + isDefaultShipping: boolean; +} + +export interface PublicSlot { + slotId: number; + timeStart: string; + timeStop: string; + /** Remaining capacity: 0 is full and -1 means unlimited. */ + availabilityStatus: number; +} + +export interface PublicSlotDay { + day: string; + slots: PublicSlot[]; +} + // --- API Client --- async function apiRequest( @@ -344,6 +369,36 @@ export async function getMe(token: AuthToken): Promise { return result; } +/** List the authenticated account's delivery addresses. */ +export async function getAddresses(token: AuthToken): Promise { + return apiRequest("/addresses/", { token }); +} + +/** Get delivery slots available for one of the account's addresses. */ +export async function getDeliverySlots( + token: AuthToken, + addressId: number, +): Promise { + return apiRequest("/slots/delivery/", { + method: "POST", + token, + body: { addressId }, + }); +} + +/** Get pickup slots grouped by Krónan store. */ +export async function getPickupSlots( + token: AuthToken, +): Promise< + Array<{ storeName: string; storeChain: string; days: PublicSlotDay[] }> +> { + return apiRequest("/slots/pickup/", { + method: "POST", + token, + body: { chain: "kronan" }, + }); +} + /** * Search for products. */ diff --git a/src/commands/slots.ts b/src/commands/slots.ts new file mode 100644 index 0000000..3d0fc4c --- /dev/null +++ b/src/commands/slots.ts @@ -0,0 +1,102 @@ +/** Read-only delivery-slot commands. */ + +import { getAddresses, getDeliverySlots, getPickupSlots } from "../api.ts"; +import { requireAuth } from "../auth.ts"; + +export async function deliverySlotsCommand( + options: { addressId?: number; json?: boolean } = {}, +): Promise { + const token = await requireAuth(); + const addresses = await getAddresses(token); + const address = options.addressId + ? addresses.find((candidate) => candidate.id === options.addressId) + : (addresses.find((candidate) => candidate.isDefaultShipping) ?? + addresses[0]); + + if (!address) { + throw new Error( + "No delivery address is configured for this Krónan account.", + ); + } + + if (options.addressId && !address) { + throw new Error( + `Address ${options.addressId} is not available to this account.`, + ); + } + + const days = await getDeliverySlots(token, address.id); + const available = days + .map((day) => ({ + ...day, + slots: day.slots.filter((slot) => slot.availabilityStatus !== 0), + })) + .filter((day) => day.slots.length > 0); + + if (options.json) { + console.log( + JSON.stringify({ addressId: address.id, days: available }, null, 2), + ); + return; + } + + if (available.length === 0) { + console.log("No delivery slots are currently available."); + return; + } + + const first = available[0]!; + const firstSlot = first.slots[0]!; + console.log( + `Next delivery: ${first.day} ${firstSlot.timeStart.slice(0, 5)}–${firstSlot.timeStop.slice(0, 5)}`, + ); + console.log("\nAvailable delivery slots:"); + for (const day of available) { + console.log(` ${day.day}`); + for (const slot of day.slots) { + const capacity = + slot.availabilityStatus === -1 + ? "available" + : `${slot.availabilityStatus} left`; + console.log( + ` ${slot.timeStart.slice(0, 5)}–${slot.timeStop.slice(0, 5)} (${capacity})`, + ); + } + } +} + +export async function pickupSlotsCommand( + options: { json?: boolean } = {}, +): Promise { + const stores = await getPickupSlots(await requireAuth()); + if (options.json) { + console.log(JSON.stringify(stores, null, 2)); + return; + } + for (const store of stores) { + const next = store.days + .flatMap((day) => day.slots.map((slot) => ({ day: day.day, ...slot }))) + .find((slot) => slot.availabilityStatus !== 0); + if (next) { + console.log( + `${store.storeName}: ${next.day} ${next.timeStart.slice(0, 5)}–${next.timeStop.slice(0, 5)}`, + ); + } + } +} + +export async function addressesCommand( + options: { json?: boolean } = {}, +): Promise { + const addresses = await getAddresses(await requireAuth()); + if (options.json) { + console.log(JSON.stringify(addresses, null, 2)); + return; + } + for (const address of addresses) { + const marker = address.isDefaultShipping ? " (default)" : ""; + console.log( + `${address.id}: ${address.streetAddress1}, ${address.postalCode} ${address.city}${marker}`, + ); + } +} diff --git a/src/index.ts b/src/index.ts index 921cd1d..ede5452 100755 --- a/src/index.ts +++ b/src/index.ts @@ -84,6 +84,11 @@ import { notesToggleCommand, notesUpdateCommand, } from "./commands/shopping-notes.ts"; +import { + addressesCommand, + deliverySlotsCommand, + pickupSlotsCommand, +} from "./commands/slots.ts"; const args = process.argv.slice(2); const command = args[0]; @@ -101,6 +106,15 @@ function hasFlag(name: string): boolean { const jsonOutput = hasFlag("json"); const dryRun = hasFlag("dry-run"); +const confirmed = hasFlag("yes"); + +function requireWriteConfirmation(): void { + if (!confirmed) { + throw new Error( + "This command changes your Krónan account. Review it and repeat with --yes to confirm.", + ); + } +} async function main() { try { @@ -180,6 +194,38 @@ async function main() { break; } + case "slots": { + const subcommand = args[1]?.startsWith("-") + ? "delivery" + : (args[1] ?? "delivery"); + if (subcommand !== "delivery" && subcommand !== "pickup") { + console.error( + "Usage: kronan slots [delivery] [--address-id N] [--json]", + ); + process.exit(1); + } + if (subcommand === "pickup") { + await pickupSlotsCommand({ json: jsonOutput }); + break; + } + const addressId = getFlag("address-id"); + const parsedAddressId = addressId ? parseInt(addressId, 10) : undefined; + if (addressId && (!parsedAddressId || parsedAddressId < 1)) { + console.error("--address-id must be a positive integer"); + process.exit(1); + } + await deliverySlotsCommand({ + addressId: parsedAddressId, + json: jsonOutput, + }); + break; + } + + case "addresses": { + await addressesCommand({ json: jsonOutput }); + break; + } + case "category": { const slug = args[1]; if (!slug) { @@ -212,6 +258,7 @@ async function main() { subcommand === "toggle-substitution" ) { // Order modification subcommands + requireWriteConfirmation(); switch (subcommand) { case "delete-lines": { const orderToken = args[2]; @@ -320,6 +367,7 @@ async function main() { console.error("Usage: kronan cart add [quantity] [--json]"); process.exit(1); } + requireWriteConfirmation(); await cartAddCommand(sku, qty, { json: jsonOutput }); break; } @@ -340,6 +388,7 @@ async function main() { process.exit(1); } const lines = parseCartLinesJson(json); + if (!dryRun) requireWriteConfirmation(); await cartSetCommand(lines, { json: jsonOutput, dryRun }); break; } @@ -357,6 +406,7 @@ async function main() { // Bulk patch when arg looks like JSON (starts with [ or {). if (identifier.startsWith("[") || identifier.startsWith("{")) { const patches = parseCartLinesJson(identifier); + if (!dryRun) requireWriteConfirmation(); await cartBulkUpdateCommand(patches, { json: jsonOutput, dryRun, @@ -379,6 +429,7 @@ async function main() { console.error("--quantity must be an integer."); process.exit(1); } + if (!dryRun) requireWriteConfirmation(); await cartUpdateCommand(identifier, qty, { json: jsonOutput, dryRun, @@ -393,6 +444,7 @@ async function main() { ); process.exit(1); } + if (!dryRun) requireWriteConfirmation(); await cartRemoveCommand(identifier, { json: jsonOutput, dryRun, @@ -400,6 +452,7 @@ async function main() { break; } case "clear": { + if (!dryRun) requireWriteConfirmation(); await cartClearCommand({ json: jsonOutput, dryRun }); break; } @@ -423,6 +476,13 @@ async function main() { const subcommand = args.find( (arg) => !arg.startsWith("-") && args.indexOf(arg) === 1, ); + if ( + ["create", "delete", "add", "remove", "clear"].includes( + subcommand ?? "", + ) + ) { + requireWriteConfirmation(); + } switch (subcommand) { case "create": { const name = args[2]; @@ -529,6 +589,13 @@ async function main() { const subcommand = args.find( (arg) => !arg.startsWith("-") && args.indexOf(arg) === 1, ); + if ( + ["add", "update", "remove", "toggle", "clear"].includes( + subcommand ?? "", + ) + ) { + requireWriteConfirmation(); + } switch (subcommand) { case "add": { const text = getFlag("text"); @@ -600,6 +667,7 @@ async function main() { // Check if args[1] is a subcommand (not a flag) const subcommand = args[1]; if (subcommand === "ignore" || subcommand === "unignore") { + requireWriteConfirmation(); const id = args[2]; if (!id || id.startsWith("-")) { console.error(`Usage: kronan stats ${subcommand} [--json]`); @@ -680,6 +748,8 @@ Products & Search: product Get product details by SKU categories List all categories category List products in a category + addresses List saved delivery addresses + slots [delivery|pickup] List delivery or pickup slots Orders: orders View order history @@ -741,10 +811,12 @@ Flags: --offset Offset for pagination --quantity Quantity for add/lower-quantity commands --description Description for lists create + --address-id Delivery address for slots (default: account default) --text Text for notes --sku SKU for notes --name Profile name for token command --force Force destructive operations + --yes Confirm a command that writes to Krónan --include-ignored Include ignored items in stats --dry-run Print planned cart lines without calling API (cart set/update/remove/clear) @@ -759,6 +831,7 @@ Examples: kronan product 02500188 kronan categories kronan category mjolkursvorur + kronan slots kronan orders --json kronan order abc123... kronan order delete-lines abc123 line1 line2