Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ kronan cart update <sku|id> --quantity N Update one line (qty 0 removes)
kronan cart update '<json-patch>' Bulk update via JSON patch
kronan cart remove <sku|id> 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
Expand Down
55 changes: 55 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(
Expand Down Expand Up @@ -344,6 +369,36 @@ export async function getMe(token: AuthToken): Promise<PublicMe> {
return result;
}

/** List the authenticated account's delivery addresses. */
export async function getAddresses(token: AuthToken): Promise<PublicAddress[]> {
return apiRequest<PublicAddress[]>("/addresses/", { token });
}

/** Get delivery slots available for one of the account's addresses. */
export async function getDeliverySlots(
token: AuthToken,
addressId: number,
): Promise<PublicSlotDay[]> {
return apiRequest<PublicSlotDay[]>("/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.
*/
Expand Down
102 changes: 102 additions & 0 deletions src/commands/slots.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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<void> {
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}`,
);
}
}
73 changes: 73 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -212,6 +258,7 @@ async function main() {
subcommand === "toggle-substitution"
) {
// Order modification subcommands
requireWriteConfirmation();
switch (subcommand) {
case "delete-lines": {
const orderToken = args[2];
Expand Down Expand Up @@ -320,6 +367,7 @@ async function main() {
console.error("Usage: kronan cart add <sku> [quantity] [--json]");
process.exit(1);
}
requireWriteConfirmation();
await cartAddCommand(sku, qty, { json: jsonOutput });
break;
}
Expand All @@ -340,6 +388,7 @@ async function main() {
process.exit(1);
}
const lines = parseCartLinesJson(json);
if (!dryRun) requireWriteConfirmation();
await cartSetCommand(lines, { json: jsonOutput, dryRun });
break;
}
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -393,13 +444,15 @@ async function main() {
);
process.exit(1);
}
if (!dryRun) requireWriteConfirmation();
await cartRemoveCommand(identifier, {
json: jsonOutput,
dryRun,
});
break;
}
case "clear": {
if (!dryRun) requireWriteConfirmation();
await cartClearCommand({ json: jsonOutput, dryRun });
break;
}
Expand All @@ -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];
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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} <id> [--json]`);
Expand Down Expand Up @@ -680,6 +748,8 @@ Products & Search:
product <sku> Get product details by SKU
categories List all categories
category <slug> List products in a category
addresses List saved delivery addresses
slots [delivery|pickup] List delivery or pickup slots

Orders:
orders View order history
Expand Down Expand Up @@ -741,10 +811,12 @@ Flags:
--offset <n> Offset for pagination
--quantity <n> Quantity for add/lower-quantity commands
--description <text> Description for lists create
--address-id <n> Delivery address for slots (default: account default)
--text <text> Text for notes
--sku <sku> SKU for notes
--name <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)
Expand All @@ -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
Expand Down
Loading