@asbirtech/toolkit
v2.6.0
Published
A collection of domain-agnostic utilities, formatters, and React hooks shared across Planout's apps (`web`, `dashboard`, and beyond). Extracted from `@planout/common` to be framework-light and reusable outside this monorepo.
Keywords
Readme
@asbirtech/toolkit
A collection of domain-agnostic utilities, formatters, and React hooks shared across Planout's apps (web, dashboard, and beyond). Extracted from @planout/common to be framework-light and reusable outside this monorepo.
Install
Inside this workspace it's available via workspace:*:
{
"dependencies": {
"@asbirtech/toolkit": "workspace:*"
}
}Peer dependencies: react / react-dom (only required if you use @asbirtech/toolkit/hooks).
Import paths
The package exposes a few subpath exports — pick the narrowest one that has what you need:
| Import path | What it contains |
|---|---|
| @asbirtech/toolkit | Everything from ./utils and ./lib (root barrel). Does not include hooks. |
| @asbirtech/toolkit/utils | All utils below, from every file in src/utils. |
| @asbirtech/toolkit/utils/url | Just the URL helpers (src/utils/url/url.ts). |
| @asbirtech/toolkit/utils/classes | Just mergeClasses. |
| @asbirtech/toolkit/lib | normalizeError (Axios/form-error normalization). |
| @asbirtech/toolkit/hooks | React hooks (useFormErrors, useScrollIntoView) — requires react. |
| @asbirtech/toolkit/* | Generic fallback resolving to any file under dist/*, e.g. @asbirtech/toolkit/utils/url/get-email-from-url for the one helper not re-exported by the ./utils/url barrel. |
import { fCurrency, mergeClasses, slugify } from "@asbirtech/toolkit";
import { useFormErrors } from "@asbirtech/toolkit/hooks";
import { normalizeError } from "@asbirtech/toolkit/lib";Hooks (@asbirtech/toolkit/hooks)
useFormErrors()
Wraps normalizeError in React state for form submission flows.
const { submissionErrors, handleError, clearErrors } = useFormErrors();
try {
await api.post("/register", data);
clearErrors();
} catch (error) {
handleError(error);
}
// submissionErrors: { message?: string; fields?: Record<string, string | string[]> } | nulluseScrollIntoView<T>(options?)
Imperatively scrolls a ref'd element into view, gated by an isReady flag — handy for jumping to the first validation error once a form re-renders.
const { targetRef, requestScroll } = useScrollIntoView<HTMLDivElement>({
isReady: !isSubmitting,
});
<div ref={targetRef} />
<button onClick={requestScroll}>Jump to error</button>Options: behavior (default "smooth"), block (default "start"), inline (default "nearest"), isReady (default true).
Lib (@asbirtech/toolkit/lib)
normalizeError(error: unknown): FormSubmissionErrors
Normalizes Axios errors, Error instances, strings, or arbitrary error-shaped objects into a consistent { message, fields } shape for form error display. Pulls field-level validation errors out of error.response.data.errors when present.
try {
await axios.post("/api/submit", data);
} catch (error) {
const { message, fields } = normalizeError(error);
}Utils (@asbirtech/toolkit/utils, also re-exported from the root)
Address — address.ts
normalizeAddress(location)
{ address?, city?, country? } | string | null | undefined → string | null
Joins non-empty address/city/country fields into a single display string; passes a plain string through unchanged; returns null for empty input.
normalizeAddress({ city: "Manila", country: "PH" }); // "Manila, PH"
normalizeAddress(null); // nullAPI request logging — api-logging.ts
Building blocks for an in-app API request/error log viewer (e.g. a debug panel that reads .jsonl log files). Every function/constant is exported under two names — a generic Log* name and an Api*-prefixed alias — both point at the same implementation, so use whichever reads better at the call site.
API_LOG_FILE_PREFIX/LOG_FILE_PREFIX—"api-"API_LOG_FILE_EXTENSION/LOG_FILE_EXTENSION—".jsonl"logTimeFilters/apiLogTimeFilters— filter chips:all,last-15m,last-1h,night,morning,afternoon,eveninglogTypeFilters/apiLogRequestTypeFilters— filter chips:all,GET,POST,PUT,DELETE,ERROR- Types:
LogEntry/ApiLogEntry,LogErrorInput/ApiErrorLogInput,LogKind/ApiLogKind,LogMethod/ApiLogMethod,LogTimeFilterId/ApiLogTimeFilterId,LogTypeFilterId/ApiLogRequestTypeFilterId
import {
createLogRequestId,
createErrorLogEntry,
serializeLogEntry,
parseLogJsonl,
matchesLogTimeFilter,
matchesLogTypeFilter,
getLogFileName,
formatLogFileSize,
} from "@asbirtech/toolkit/utils";
// writing a log line
const entry = createErrorLogEntry(caughtError, { operation: "checkout.submit" });
appendToFile(getLogFileName("2026-08-03"), serializeLogEntry(entry) + "\n");
// reading + filtering in a viewer
const entries = parseLogJsonl(fileContents); // sorted newest-first
const visible = entries.filter(
(e) => matchesLogTimeFilter(e, "last-1h") && matchesLogTypeFilter(e, "GET"),
);Other helpers: createLogRequestId(), normalizeLogError(error), parseLogLine(line), safeStringifyLog(value) (handles circular refs/bigints), sortLogEntriesNewestFirst(entries), formatLogDateKey(date), isLogFileName(fileName), dateFromLogFileName(fileName), isLogDateKey(value), formatLogDateLabel(date), formatLogTimeLabel(timestamp), formatLogFileSize(size), getLogStatusLabel(entry), getLogScreenLabel(entry).
Boolean env parsing — boolean.ts
parseBoolEnv(value: string | undefined, defaultValue: boolean): boolean
Parses env-var-style strings ("true"/"1"/"yes"/"on" → true, "false"/"0"/"no"/"off"/"" → false) into a boolean, falling back to defaultValue for anything else (including undefined).
const debugLogging = parseBoolEnv(process.env.DEBUG_LOGGING, false);Class names — classes/classes.ts
mergeClasses(className?, state?): string
Merges a base class name (string or array of strings) with conditionally-applied state classes. A state value can be a plain boolean (uses the object key as the class name) or a [condition, className] tuple to apply a different class name than the key.
mergeClasses("btn", {
active: isActive,
disabled: [!canSubmit, "btn--disabled"],
});
// "btn active btn--disabled" (when isActive && !canSubmit)Import via @asbirtech/toolkit/utils/classes or the root/utils barrel.
Currency — currency.ts
currencyFormatter — a preconfigured Intl.NumberFormat (en-PH, PHP, 0 fraction digits).
getNumericValue(value?: string | number | null): number — coerces to a number, defaulting to 0 for invalid/empty input.
currencyFormatter.format(1500); // "₱1,500"
getNumericValue("42.5"); // 42.5
getNumericValue(null); // 0For locale-configurable currency formatting, see
fCurrencyinformat-number.tsbelow.
Dates — date.ts
Dayjs-based formatting/comparison helpers (extends the duration and relativeTime plugins internally — no setup needed).
formatPatterns— reusable dayjs format strings (dateTime,dateTimeWithSeconds,date,time, plussplit/paramCaseDD/MM variants)today(template?)→ today at start of day, formattedfDateTime(date, template?)→"Apr 17, 2022 12:00 am"(or"Invalid date")fDateTimeWithSeconds(date, template?)→"Sep 15, 2025 05:28:03 PM"fDate(date, template?)→"Apr 17, 2022"fTime(date, template?)→"12:00 am"formatEventDate(date)→"June 27, 2025"formatEventDateWithDay(date)→"Friday, June 27, 2025"formatEventTime(date)→"4:00 AM GMT+00:00"formatEventDateTime(date)→{ date, time }formatShortDate(date, format?: "US" | "EU")→"04-25-25"or"25-04-25"formatUnixTimestampDate(timestamp?)→"Jun 27, 2025"or"—"fornull/undefinedfTimestamp(date)→ Unix seconds, or"Invalid date"fToNow(date)→ relative time, e.g."2 years","a few seconds"fIsBetween(inputDate, startDate, endDate)/fIsAfter(startDate, endDate)/fIsSame(startDate, endDate, unit?)→boolean(fIsSamedefaults to comparing by"year")fDateRangeShortLabel(startDate, endDate, initial?)→ smart range labelfAdd(durationProps)/fSub(durationProps)→ ISO string of now ± a duration ({ years, months, days, hours, minutes, seconds, milliseconds }, all optional)
formatEventDateTime(event.startsAt); // { date: "June 27, 2025", time: "4:00 AM GMT+00:00" }
fDateRangeShortLabel(event.startsAt, event.endsAt); // "April 25-26, 2024"
fAdd({ days: 7 }); // one week from now, ISO string
fToNow(comment.createdAt); // "3 hours"Types: DatePickerFormat, EventDateInput, ShortDateFormat, DurationProps.
Number/currency/percent formatting — format-number.ts
Locale-aware formatters with a settable global locale (defaults to fil-PH / PHP).
setNumberLocale({ code, currency }) — configures the locale used by every formatter below. Call once at app startup if you need a different locale/currency than the default.
fNumber(value, options?)→ formatted number,""for invalid inputfCurrency(value, options?)→ formatted currency stringfPercent(value, options?)→ formatted percent; input is on a 0–100 scale (50→"50%")fShortenNumber(value, options?)→ compact notation, lowercased suffix ("1.2k")fData(value)→ byte-size string ("1.5 Mb"),"0 bytes"for0/invalidcalculateTotalPercentageAmount(percentage, originalAmount)→numberformatPhpCurrency(value: number)→ fixeden-PH/PHP formatted string, independent ofsetNumberLocaleparseAmount(value?)→ strips non-numeric characters and parses to anumber, defaulting to0
setNumberLocale({ code: "en-US", currency: "USD" });
fCurrency(19.99); // "$19.99"
fShortenNumber(15000); // "15k"
fData(1536); // "1.5 Kb"
parseAmount("₱1,250.00"); // 1250Type: InputNumberValue = string | number | null | undefined.
HTML — html.ts
Browser-only (no-op — returns input unchanged — when document is undefined, e.g. during SSR).
decodeHtmlEntities(text: string): string — decodes entities like & via a throwaway <textarea>.
stripHtmlTags(html: string): string — strips tags, returning the plain text content.
stripHtmlTags("<p>Hello <b>world</b></p>"); // "Hello world"
decodeHtmlEntities("Tom & Jerry"); // "Tom & Jerry"Images — image.ts
SupportedFileFormat—["jpeg", "jpg", "png", "webp", "heic", "heif", "pdf"]ImageFileFormat— same, minus"pdf"getAcceptedTypes()→".jpeg,.jpg,.png,.webp,.heic,.heif,.pdf"(for<input accept>)getAcceptedImageTypes()→ same, image-only (no pdf)getAcceptedImageTypesForInput()→"image/jpeg,image/png,..."MIME list
compressAndConvertToJpeg(file: File, quality = 0.8, maxWidthOrHeight = 1920): Promise<File>
Converts HEIC/HEIF to JPEG (via heic2any), then compresses via browser-image-compression. Browser-only — dynamically imports both libraries so importing this module doesn't break SSR; returns the original file unchanged if called on the server.
<input
type="file"
accept={getAcceptedImageTypesForInput()}
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
const compressed = await compressAndConvertToJpeg(file);
uploadFile(compressed);
}
}}
/>Masking — mask.ts
maskEmail(email: string): string → "jo***@example.com" (keeps up to 2 leading local-part characters).
maskPhone(phone: string): string → masks all but the last 4 digits, e.g. "+15551234567" → "***********4567".
maskEmail("[email protected]"); // "jo***@example.com"
maskPhone("+15551234567"); // "***********4567"Localhost detection — network.ts
isLocalhost: boolean
true when running in a browser on localhost, 127.0.0.1, or a 192.168.x.x address — useful for gating dev-only UI or behavior.
if (isLocalhost) console.log("dev mode");Numbers — number.ts
toFiniteNumber(value: unknown): number | null
Coerces a value to a positive integer; returns null for 0, negatives, non-integers, or anything that doesn't parse.
parseNonNegativeInteger(value: string | undefined, fallback: number): number
Parses a non-negative integer from a string (e.g. a query param), returning fallback if invalid.
toFiniteNumber("42"); // 42
toFiniteNumber("-3"); // null
parseNonNegativeInteger(searchParams.get("page") ?? undefined, 1); // 1Query strings — query-string.ts
createQueryString(currentSearchParams, params): string
Merges new key/value pairs into an existing query string (string or URLSearchParams); a null/undefined/"" value in params deletes that key.
createQueryString(window.location.search, { page: 2, filter: null });
// "page=2" — filter removed if it existedNext.js searchParams helpers — search-params.ts
buildQueryString(searchParams?: SearchParams): string
Turns a Next.js searchParams object (Record<string, string | string[] | undefined>) into a "?a=1&b=2" string, or "" when empty.
buildUrlWithParams(basePath, params): string
Appends a query string to a path, skipping any undefined/null/"" values.
buildQueryString({ category: ["music", "art"], q: "search" }); // "?category=music&category=art&q=search"
buildUrlWithParams("/events", { category: "music", page: undefined }); // "/events?category=music"Type: SearchParams.
Scrolling — scroll.ts
scrollToId(id: string, options?: ScrollIntoViewOptions): boolean
Finds an element by id and scrolls it into view (default { behavior: "smooth", block: "center" }); returns whether the element existed. Browser-only (false during SSR).
scrollToId("section-2"); // scrolls smoothly, centers the element, returns true/falseFor scroll-on-ready-condition flows tied to React state, prefer the
useScrollIntoViewhook.
Strings — string.ts
slugify(text: string): string — lowercase, hyphenated slug.
sanitizeDashes(source: string): string — replaces dashes/underscores with spaces and trims to the first 3 words.
toReadableLabel(value: string): string — converts camelCase/kebab-case/snake_case into "Title Case" words.
parseBoolean(value?: string): boolean — strictly true only when the trimmed, lowercased string equals "true".
toReadableLabel("firstName"); // "First Name"
toReadableLabel("event-status"); // "Event Status"
slugify("Mountaineering Club!"); // "mountaineering-club"
parseBoolean("True"); // true
parseBoolean("yes"); // false — only "true" countsNote:
slugify(here) andtoSlug(below,to-slug.ts) implement very similar slug logic independently — both are exported (under different names, so no collision), but treat them as the same feature when reaching for one.
Slugs — to-slug.ts
toSlug(str: string): string
Converts a string to a URL-friendly slug (lowercase, hyphenated, strips special characters).
toSlug("Mountaineering Club"); // "mountaineering-club"Uploads — upload.ts
extractUploadedFileId(value: unknown): number | null
Pulls a numeric file id out of varied upload-response shapes — a raw number/string, { file_id }, { id }, or a nested { data: { file_id | id } } — using toFiniteNumber for coercion.
extractUploadedFileId({ data: { file_id: "123" } }); // 123
extractUploadedFileId(null); // nullEnvironment variables — env.ts
getRequiredEnv(name: string, value: string | undefined): string
Guards a required environment variable at startup, throwing a clear error instead of silently continuing with undefined.
const apiUrl = getRequiredEnv(
"NEXT_PUBLIC_REST_API_ENDPOINT",
process.env.NEXT_PUBLIC_REST_API_ENDPOINT,
);URLs — url/url.ts (also @asbirtech/toolkit/utils/url)
hasParams(url: string): boolean — whether a URL string has any query params (SSR-safe).
removeLastSlash(pathname: string): string — strips one trailing slash (leaves "/" alone).
isEqualPath(targetUrl, currentUrl, options?: { deep?: boolean }): boolean — compares pathnames (and, when deep: true, query params too — the default) after normalizing trailing slashes.
removeParams(url: string): string — returns just the pathname, stripped of the query string. Resolves relative URLs against window.location.origin, so it's effectively client-only for relative input.
isExternalLink(url: string): boolean — true if the URL starts with http:///https://.
buildFullUrl(baseURL, url): string — joins a base URL and a path; if url is already an external link, returns it unchanged.
safeReturnUrl(value: string | null, fallback?: string | null): string — validates that a redirect URL is a safe, same-origin, non-junk internal path before using it (open-redirect protection). Defaults the fallback to "/".
isExternalLink("https://google.com"); // true
buildFullUrl("https://api.planout.io", "/events"); // "https://api.planout.io/events"
safeReturnUrl(router.query.redirect as string, "/dashboard"); // safe internal path, or "/dashboard"
isEqualPath("/dashboard/", "/dashboard"); // trueType: EqualPathOptions.
Email prefill from URL — url/get-email-from-url.ts
Not re-exported from the
./utils/urlor./utilsbarrels — import it via the generic subpath:@asbirtech/toolkit/utils/url/get-email-from-url.
getPrefillEmail(searchParams: Pick<URLSearchParams, "get">): string | null
Extracts an email to prefill on an auth/login page, checking either a direct ?email= param or a nested ?redirect=...?email=... param (handles space→+ normalization and URL decoding).
import { getPrefillEmail } from "@asbirtech/toolkit/utils/url/get-email-from-url";
getPrefillEmail(new URLSearchParams(location.search)); // "[email protected]"Development
pnpm --filter @asbirtech/toolkit build # tsc build to dist/
pnpm --filter @asbirtech/toolkit dev # tsc --watch
pnpm --filter @asbirtech/toolkit test # vitest run
pnpm --filter @asbirtech/toolkit check-types # tsc --noEmit
pnpm --filter @asbirtech/toolkit lint # eslint srcThe package is consumed via its built dist/ output by other workspace packages — after editing src/, rebuild (or run the dev watcher) before changes are picked up elsewhere in the monorepo.
