@saasak/utils-filter
v0.0.7
Published
A collection filtering protocol
Readme
@saasak/utils-filter
A collection filtering protocol that provides a simple way to pass complex filters between forms and backends using URL-safe strings instead of JSON.
Why utils-filter?
When building forms with complex filtering capabilities, you need a way to serialize filter states into URLs or query parameters. While JSON.stringify() works, it creates verbose, hard-to-read URLs. This module provides a clean, URL-safe format that's both human-readable and efficient.
Before (JSON):
?filters=%7B%22category%22%3A%22products%22%2C%22price%22%3A%7B%22min%22%3A10%2C%22max%22%3A100%7D%2C%22isActive%22%3Atrue%7DAfter (utils-filter):
?filters=category%3Aproducts%7Cprice%3A10%2C100%7CisActive%3AtrueFeatures
- ✅ Timezone-aware date filtering - No more partial days in database queries
- ✅ Time-series bucketing - Timezone metadata propagates to SQL queries
- ✅ Auto timezone detection - Seamless experience for users worldwide
- ✅ DST handling - Correctly handles Daylight Saving Time transitions
- ✅ Shared links - Same UTC data, appropriate display per timezone
- ✅ SQL query helpers - Generate timezone-aware
DATE_TRUNCqueries - ✅ Type-safe - Full TypeScript support
- ✅ URL-friendly - Clean, readable query parameters
- ✅ Multi-database support - Postgres, MySQL, SQLite, and ClickHouse
Installation
npm install @saasak/utils-filter
# or
pnpm add @saasak/utils-filter
# or
yarn add @saasak/utils-filterBasic Usage
import { parseFilters, toFilterString, FilterDefinition } from '@saasak/utils-filter';
// Define your filter schema
const filterDefinitions: FilterDefinition[] = [
{ id: 'category', type: 'radio', values: ['products', 'services'] },
{ id: 'price', type: 'number', min: 0, max: 1000 },
{ id: 'isActive', type: 'boolean' },
{ id: 'tags', type: 'checkbox', values: ['featured', 'new', 'sale'] }
];
// Parse a filter string
const filterString = 'category:products|price:50,100|isActive:true|tags:featured,new';
const parsedFilters = parseFilters(filterString, filterDefinitions);
// Convert back to filter string
const serialized = toFilterString(parsedFilters, filterDefinitions);Supported Filter Types
1. Boolean Filters
Boolean filters represent true/false states with three possible values:
1(true)-1(false)0(null/indeterminate)
const definitions = [{ id: 'isActive', type: 'boolean' }];
// Parsing
parseFilters('isActive:true', definitions); // { isActive: 1 }
parseFilters('isActive:false', definitions); // { isActive: -1 }
parseFilters('isActive:null', definitions); // { isActive: 0 }
// Serializing
toFilterString({ isActive: true }, definitions); // "isActive:true"
toFilterString({ isActive: false }, definitions); // "isActive:false"
toFilterString({ isActive: null }, definitions); // "" (empty)Supported boolean values:
true:true,1,yes,on,checkedfalse:false,-1,no,off,unchecked
2. String Filters
Simple text filters for search terms, names, etc.
const definitions = [{ id: 'search', type: 'string' }];
// Parsing
parseFilters('search:hello world', definitions); // { search: "hello world" }
parseFilters('search:', definitions); // { search: "" }
// Serializing
toFilterString({ search: 'hello world' }, definitions); // "search:hello world"
toFilterString({ search: null }, definitions); // "" (empty)3. Number Filters
Numeric filters with optional min/max constraints.
const definitions = [
{ id: 'price', type: 'number' },
{ id: 'age', type: 'number', min: 0, max: 120 }
];
// Parsing
parseFilters('price:25.50', definitions); // { price: 25.50 }
parseFilters('age:30', definitions); // { age: 30 }
parseFilters('age:150', definitions); // { age: null } (exceeds max)
// Serializing
toFilterString({ price: 25.5 }, definitions); // "price:25.50"
toFilterString({ age: null }, definitions); // "" (empty)4. Array Filters
Comma-separated lists of values.
const definitions = [{ id: 'tags', type: 'array' }];
// Parsing
parseFilters('tags:red,blue,green', definitions); // { tags: ["red", "blue", "green"] }
parseFilters('tags:', definitions); // { tags: [] }
// Serializing
toFilterString({ tags: ['red', 'blue', 'green'] }, definitions); // "tags:red,blue,green"
toFilterString({ tags: [] }, definitions); // "tags:"5. Radio Filters
Single selection from predefined options.
const definitions = [
{ id: 'category', type: 'radio', values: ['products', 'services', 'support'] }
];
// Parsing
parseFilters('category:products', definitions); // { category: "products" }
parseFilters('category:invalid', definitions); // { category: null }
// Serializing
toFilterString({ category: 'products' }, definitions); // "category:products"
toFilterString({ category: 'invalid' }, definitions); // "" (empty - not in allowed values)6. Checkbox Filters
Multiple selection from predefined options.
const definitions = [
{ id: 'features', type: 'checkbox', values: ['premium', 'basic', 'enterprise'] }
];
// Parsing
parseFilters('features:premium,enterprise', definitions); // { features: ["premium", "enterprise"] }
parseFilters('features:invalid,premium', definitions); // { features: ["premium"] } (invalid filtered out)
// Serializing
toFilterString({ features: ['premium', 'enterprise'] }, definitions); // "features:premium,enterprise"
toFilterString({ features: [] }, definitions); // "" (empty)7. Date Filters
Single date values in ISO format.
const definitions = [{ id: 'createdDate', type: 'date' }];
// Parsing
parseFilters('createdDate:2024-01-15T10:30:00.000Z', definitions);
// { createdDate: Date object }
parseFilters('createdDate:invalid-date', definitions); // { createdDate: null }
// Serializing
toFilterString({ createdDate: new Date('2024-01-15T10:30:00.000Z') }, definitions);
// "createdDate:2024-01-15T10:30:00.000Z"8. Date Range Filters
Date ranges with start and end dates.
const definitions = [{ id: 'dateRange', type: 'date_range' }];
// Parsing
parseFilters('dateRange:2024-01-01T00:00:00.000Z,2024-12-31T23:59:59.999Z', definitions);
// { dateRange: { start: Date, end: Date } }
// Serializing
toFilterString(
{
dateRange: {
start: new Date('2024-01-01T00:00:00.000Z'),
end: new Date('2024-12-31T23:59:59.999Z')
}
},
definitions
);
// "dateRange:2024-01-01T00:00:00.000Z,2024-12-31T23:59:59.999Z"Advanced Usage
Mixed Filter Types
You can combine any filter types in a single filter string:
const definitions = [
{ id: 'name', type: 'string' },
{ id: 'category', type: 'radio', values: ['products', 'services'] },
{ id: 'price', type: 'number', min: 0, max: 1000 },
{ id: 'isActive', type: 'boolean' },
{ id: 'tags', type: 'checkbox', values: ['featured', 'new'] },
{ id: 'createdDate', type: 'date' },
{ id: 'dateRange', type: 'date_range' }
];
const filterString =
'name:widget|category:products|price:50,100|isActive:true|tags:featured,new|createdDate:2024-01-15T10:30:00.000Z|dateRange:2024-01-01T00:00:00.000Z,2024-12-31T23:59:59.999Z';
const parsed = parseFilters(filterString, definitions);
// {
// name: "widget",
// category: "products",
// price: 50,
// isActive: 1,
// tags: ["featured", "new"],
// createdDate: Date object,
// dateRange: { start: Date, end: Date }
// }URL Integration
The filter strings are URL-safe and can be used directly in query parameters:
// In your form component
const updateFilters = (newFilters) => {
const filterString = toFilterString(newFilters, filterDefinitions);
const url = new URL(window.location);
url.searchParams.set('filters', filterString);
window.history.pushState({}, '', url);
};
// On page load
const urlParams = new URLSearchParams(window.location.search);
const filterString = urlParams.get('filters') || '';
const currentFilters = parseFilters(filterString, filterDefinitions);Form Integration Example
// React example
const FilterForm = () => {
const [filters, setFilters] = useState({});
const handleFilterChange = (filterId, value) => {
const newFilters = { ...filters, [filterId]: value };
setFilters(newFilters);
// Update URL
const filterString = toFilterString(newFilters, filterDefinitions);
const url = new URL(window.location);
url.searchParams.set('filters', filterString);
window.history.pushState({}, '', url);
};
const loadFiltersFromURL = () => {
const urlParams = new URLSearchParams(window.location.search);
const filterString = urlParams.get('filters') || '';
const parsedFilters = parseFilters(filterString, filterDefinitions);
setFilters(parsedFilters);
};
useEffect(() => {
loadFiltersFromURL();
}, []);
return (
<form>
<input
value={filters['search'] || ''}
onChange={(e) => handleFilterChange('search', e.target.value)}
placeholder="Search..."
/>
{/* Other form controls */}
</form>
);
};API Reference
parseFilters(filters: string, filterDefinitions: FilterDefinition[]): Record<string, any>
Parses a filter string into an object with filter values.
Parameters:
filters: URL-encoded filter stringfilterDefinitions: Array of filter definitions
Returns: Object with filter values
toFilterString(values: FilterValue, filterDefinitions: FilterDefinition[]): string
Converts filter values back to a URL-encoded filter string.
Parameters:
values: Object with filter valuesfilterDefinitions: Array of filter definitions
Returns: URL-encoded filter string
FilterDefinition
type FilterDefinition = {
id: string;
} & (
| { type: 'boolean' | 'string' | 'array' }
| { type: 'radio' | 'checkbox'; values: string[] }
| { type: 'number'; min?: number; max?: number }
| { type: 'date' }
| { type: 'date_range' }
);FilterValue
type FilterValue = Record<
string,
string | string[] | number | null | boolean | Date | { start: Date; end: Date }
>;getTimezoneSQLHelpers(columnName: string, value: { value: any; timezone?: string }, options?: { database?: 'postgres' | 'mysql' | 'sqlite' | 'clickhouse' })
Generates timezone-aware SQL fragments for different database systems.
Parameters:
columnName: The database column name to use in SQL queriesvalue: Object containing the filter value and optional timezone informationoptions: Configuration object with database type (defaults to 'postgres')
Returns: Object with timezone-aware SQL helpers:
{
timezone: string | null; // The timezone string or null
atTimeZone: string; // SQL fragment for timezone conversion
dateTrunc: (unit: 'day' | 'week' | 'month' | 'year') => string; // Timezone-aware date truncation
}Example:
// For a date range filter with timezone
const dateRange = {
start: new Date('2024-06-15T00:00:00Z'),
end: new Date('2024-06-20T23:59:59Z'),
timezone: 'Europe/Paris'
};
const helpers = getTimezoneSQLHelpers('created_at', dateRange, { database: 'postgres' });
// Generates:
// helpers.atTimeZone = "AT TIME ZONE 'Europe/Paris'"
// helpers.dateTrunc('day') = "DATE_TRUNC('day', created_at AT TIME ZONE 'Europe/Paris')"Format Specification
The filter string format uses:
|to separate different filters:to separate filter ID from value,to separate array items or date range values- URL encoding for special characters
Examples:
name:John- Simple string filterprice:10- Numbertags:red,blue,green- Array of valuesisActive:true- Boolean filtercreatedDate:2024-01-15T10:30:00.000Z- Date filterdateRange:2024-01-01T00:00:00.000Z,2024-12-31T23:59:59.999Z- Date range
Date Handling & Timezone Management
Overview
The library provides comprehensive timezone support to ensure users get exactly the date ranges they intend, regardless of their timezone. This solves the common "partial days" problem where database queries return incomplete data due to timezone mismatches.
Smart Date Normalization with Timezone Context
The library intelligently handles date normalization based on input format and timezone:
- HTML date inputs (
YYYY-MM-DD) with timezone: Normalized to start/end of day in user's timezone - Full ISO dates with time: Preserves original time information
- Auto timezone detection: Automatically uses user's browser timezone
- DST-aware: Correctly handles Daylight Saving Time transitions
- Shared links: Same UTC range displayed appropriately in recipient's timezone
Basic Usage with Timezone
import { parseFilters, toFilterString } from '@saasak/utils-filter';
// Define filters with timezone support
const filterDefinitions = [
{
id: 'date_range',
type: 'date_range',
timezone: 'auto' // Auto-detect user's timezone
}
];
// User in France (UTC+2) selects June 15-20
const filterValues = {
date_range: {
start: '2024-06-15', // HTML date input
end: '2024-06-20'
}
};
// Serialize (automatically converts to UTC)
const filterString = toFilterString(filterValues, filterDefinitions);
// Result: "date_range:2024-06-14T22:00:00Z,2024-06-20T21:59:59Z"
// ✅ June 15 00:00 CEST = June 14 22:00 UTC (no partial days!)
// Parse back
const parsed = parseFilters(filterString, filterDefinitions);
// Returns: { start: Date(2024-06-14T22:00:00Z), end: Date(2024-06-20T21:59:59Z) }Explicit Timezone Configuration
// Specify timezone explicitly
const parisDefinitions = [
{
id: 'date_range',
type: 'date_range',
timezone: 'Europe/Paris' // IANA timezone
}
];
// Or use auto-detection
const autoDefinitions = [
{
id: 'date_range',
type: 'date_range',
timezone: 'auto' // Uses Intl.DateTimeFormat().resolvedOptions().timeZone
}
];String Date Support
The library accepts both Date objects and ISO date strings:
// Both of these work identically
toFilterString({ created_date: new Date('2024-01-15T10:30:00.000Z') }, definitions);
toFilterString({ created_date: '2024-01-15T10:30:00.000Z' }, definitions);
// Date ranges with mixed types
toFilterString(
{
dateRange: {
start: '2024-01-01T00:00:00.000Z',
end: new Date('2024-12-31T23:59:59.999Z')
}
},
definitions
);Solving the "Partial Days" Problem
Without timezone handling, database queries often return incomplete data:
// ❌ PROBLEM: User in France (UTC+2) queries June 15-20 without timezone
// Database query: WHERE date >= '2024-06-15 00:00:00' AND date <= '2024-06-20 23:59:59'
// Result: Gets June 14 22:00-23:59 (2 hours) + full June 15-20 data
// → Extra partial day at the start!
// ✅ SOLUTION: Use timezone-aware filters
const definitions = [{ id: 'date_range', type: 'date_range', timezone: 'auto' }];
const filterString = toFilterString(
{ date_range: { start: '2024-06-15', end: '2024-06-20' } },
definitions
);
// Result: "date_range:2024-06-14T22:00:00Z,2024-06-20T21:59:59Z"
// SQL Query with correct UTC boundaries
const parsed = parseFilters(filterString, definitions);
const sql = `
SELECT * FROM events
WHERE created_at >= '${parsed.date_range.start.toISOString()}'
AND created_at < '${new Date(parsed.date_range.end.getTime() + 1).toISOString()}'
`;
// Query: WHERE created_at >= '2024-06-14T22:00:00.000Z'
// AND created_at < '2024-06-20T22:00:00.000Z'
// ✅ Clean boundaries! No partial days!Time-Series Bucketing with Timezone
Critical for Analytics: When building time-series queries (daily/weekly aggregations), you need timezone-aware bucketing to avoid mixed days:
import { parseFilters, extendFiltersWithMeta, getTimezoneSQLHelpers } from '@saasak/utils-filter';
// Parse filters (automatically captures timezone metadata)
const definitions = [{ id: 'date_range', type: 'date_range', timezone: 'Europe/Paris' }];
const parsed = parseFilters(filterString, definitions);
const extended = extendFiltersWithMeta(parsed)
// Timezone metadata is automatically captured
console.log(parsed.__meta?.timezones);
// { date_range: 'Europe/Paris' }
console.log(extended.date_range)
// { value: '', timezone: 'Europe/Paris'}
// Get SQL helpers for timezone-aware queries
const sqlHelpers = getTimezoneSQLHelpers('created_at', extended.date_range, {
database: 'postgres' // or 'mysql', 'sqlite', 'clickhouse'
});
// Build time-series query with correct timezone
const timeSeriesSQL = `
SELECT
${sqlHelpers.dateTrunc('day')} as day,
COUNT(*) as count
FROM sales
WHERE created_at >= '${parsed.date_range.start.toISOString()}'
AND created_at < '${new Date(parsed.date_range.end.getTime() + 1).toISOString()}'
GROUP BY day
ORDER BY day
`;
// Expands to (Postgres):
// SELECT
// DATE_TRUNC('day', created_at AT TIME ZONE 'Europe/Paris') as day,
// COUNT(*) as count
// FROM sales ...
// Expands to (ClickHouse):
// SELECT
// toStartOfDay(timezone('Europe/Paris', created_at)) as day,
// COUNT(*) as count
// FROM sales ...
// ✅ Day buckets aligned with France timezone!
// ✅ No mixed days across timezones!
// ✅ Handles DST transitions correctly!UTC vs Timezone-Aware Bucketing Comparison
-- ❌ WITHOUT TIMEZONE (UTC bucketing) - WRONG for France user
SELECT DATE_TRUNC('day', created_at) as day, COUNT(*)
FROM sales
WHERE created_at >= '2024-06-14T22:00:00Z'
AND created_at < '2024-06-20T22:00:00Z'
GROUP BY day
-- Result:
-- 2024-06-14: 10 records ❌ Only 2 hours (22:00-23:59)
-- 2024-06-15: 120 records ⚠️ Mixed: June 15-16 France time
-- 2024-06-20: 92 records ⚠️ Only 22 hours (00:00-21:59)
-- ✅ WITH TIMEZONE (France bucketing) - CORRECT
SELECT DATE_TRUNC('day', created_at AT TIME ZONE 'Europe/Paris') as day, COUNT(*)
FROM sales
WHERE created_at >= '2024-06-14T22:00:00Z'
AND created_at < '2024-06-20T22:00:00Z'
GROUP BY day
-- Result:
-- 2024-06-15: 120 records ✅ All June 15 data (France time)
-- 2024-06-16: 115 records ✅ All June 16 data (France time)
-- 2024-06-20: 102 records ✅ All June 20 data (France time)ClickHouse-Specific Timezone Functions
ClickHouse provides native timezone support with its own function syntax:
// ClickHouse timezone-aware SQL generation
const clickhouseHelpers = getTimezoneSQLHelpers('created_at', extended.date_range, {
database: 'clickhouse'
});
// Available ClickHouse functions:
console.log(clickhouseHelpers.atTimeZone); // "timezone('Europe/Paris', created_at)"
console.log(clickhouseHelpers.dateTrunc('day')); // "toStartOfDay(timezone('Europe/Paris', created_at))"
console.log(clickhouseHelpers.dateTrunc('week')); // "toStartOfWeek(timezone('Europe/Paris', created_at))"
console.log(clickhouseHelpers.dateTrunc('month')); // "toStartOfMonth(timezone('Europe/Paris', created_at))"
console.log(clickhouseHelpers.dateTrunc('year')); // "toStartOfYear(timezone('Europe/Paris', created_at))"ClickHouse Timezone Functions:
timezone('timezone_name', column)- Convert to specific timezonetoStartOfDay()- Truncate to start of daytoStartOfWeek()- Truncate to start of weektoStartOfMonth()- Truncate to start of monthtoStartOfYear()- Truncate to start of year
Example ClickHouse Query:
-- ✅ ClickHouse timezone-aware bucketing
SELECT
toStartOfDay(timezone('Europe/Paris', created_at)) as day,
COUNT(*) as count
FROM sales
WHERE created_at >= '2024-06-14T22:00:00Z'
AND created_at < '2024-06-20T22:00:00Z'
GROUP BY day
ORDER BY day
-- Result:
-- 2024-06-15: 120 records ✅ All June 15 data (France time)
-- 2024-06-16: 115 records ✅ All June 16 data (France time)
-- 2024-06-20: 102 records ✅ All June 20 data (France time)HTML Date Input Integration
Perfect for HTML date inputs - seamless user experience:
// From HTML <input type="date"> - automatically handled
const filterDefinitions = [{ id: 'created_date', type: 'date', timezone: 'auto' }];
const htmlDateValue = '2024-06-15'; // From input.value
const filterString = toFilterString({ created_date: htmlDateValue }, filterDefinitions);
// Automatically converts to user's timezone:
// France (UTC+2): "created_date:2024-06-14T22:00:00Z"
// NYC (UTC-4): "created_date:2024-06-15T04:00:00Z"Common Integration Patterns
Timezone Handling for Date Ranges
When users select date ranges in different timezones (especially during DST transitions), you need to ensure the correct timezone context is preserved:
// For user-facing date ranges, always work in the user's timezone
import { fromZonedTime, toZonedTime, format } from 'date-fns-tz';
const createDateRangeInTimezone = (startDate: string, endDate: string, timezone: string) => {
// Convert HTML dates to user's timezone, then to UTC for storage
const start = fromZonedTime(`${startDate}T00:00:00`, timezone);
const end = fromZonedTime(`${endDate}T23:59:59.999`, timezone);
return { start, end };
};
// Example: User in France during DST (CEST = UTC+2)
const franceRange = createDateRangeInTimezone('2024-06-15', '2024-06-20', 'Europe/Paris');
// Results in UTC times that represent the correct local day boundaries
// To display back to user in their timezone
const displayDateRange = (range: { start: Date; end: Date }, timezone: string) => {
const localStart = toZonedTime(range.start, timezone);
const localEnd = toZonedTime(range.end, timezone);
return {
start: format(localStart, 'yyyy-MM-dd'),
end: format(localEnd, 'yyyy-MM-dd')
};
};Handling DST Transitions
During Daylight Saving Time transitions, a single "day" might have 23 or 25 hours:
// Spring forward (2 AM becomes 3 AM) - 23-hour day
const springForward = createDateRangeInTimezone('2024-03-31', '2024-03-31', 'Europe/Paris');
// Correctly handles the missing hour
// Fall back (3 AM becomes 2 AM) - 25-hour day
const fallBack = createDateRangeInTimezone('2024-10-27', '2024-10-27', 'Europe/Paris');
// Correctly handles the duplicate hourReal-World Example: France DST
// User in France selects June 15-20, 2024 (during DST = UTC+2)
const franceUserRange = createDateRangeInTimezone('2024-06-15', '2024-06-20', 'Europe/Paris');
// Results in:
// start: 2024-06-14T22:00:00.000Z (June 15 00:00 CEST = June 14 22:00 UTC)
// end: 2024-06-20T21:59:59.999Z (June 20 23:59 CEST = June 20 21:59 UTC)
// When serialized with utils-filter:
const filterString = toFilterString({ date_range: franceUserRange }, definitions);
// "date_range:2024-06-14T22:00:00Z,2024-06-20T21:59:59Z"
// This ensures the user gets exactly the days they selected in their timezoneSvelte Component Example (Simplified with Auto Timezone)
<script>
import { parseFilters, toFilterString } from '@saasak/utils-filter';
let filterValues = {
date_range: { start: '', end: '' }
};
// Define filter with auto timezone detection
const filterDefinitions = [{ id: 'date_range', type: 'date_range', timezone: 'auto' }];
// Simple handler - no manual timezone conversion needed!
function handleDateChange() {
if (filterValues.date_range.start && filterValues.date_range.end) {
const filterString = toFilterString(filterValues, filterDefinitions);
// filterString automatically contains correct UTC times
updateURL(filterString);
}
}
function updateURL(filterString) {
const url = new URL(window.location);
url.searchParams.set('filters', filterString);
window.history.pushState({}, '', url);
}
</script>
<div class="flex items-center gap-2">
<input
type="date"
bind:value={filterValues.date_range.start}
on:change={handleDateChange}
placeholder="From"
/>
<span class="text-xs text-gray-500"> / </span>
<input
type="date"
bind:value={filterValues.date_range.end}
on:change={handleDateChange}
placeholder="To"
/>
</div>
<!-- The filter string automatically handles timezone conversion! -->Advanced Svelte Example (Explicit Timezone Control)
<script>
import { parseFilters, toFilterString, displayDateRange } from '@saasak/utils-filter';
let selectedTimezone = 'Europe/Paris';
let filterValues = {};
const filterDefinitions = [
{
id: 'date_range',
type: 'date_range',
timezone: selectedTimezone
}
];
// User selects different timezone
function handleTimezoneChange(newTimezone) {
selectedTimezone = newTimezone;
// Re-serialize with new timezone if needed
if (filterValues.date_range) {
const filterString = toFilterString(filterValues, [
{ id: 'date_range', type: 'date_range', timezone: newTimezone }
]);
updateFilters(filterString);
}
}
</script>
<select bind:value={selectedTimezone} on:change={() => handleTimezoneChange(selectedTimezone)}>
<option value="auto">My Timezone</option>
<option value="Europe/Paris">Paris</option>
<option value="America/New_York">New York</option>
<option value="Asia/Tokyo">Tokyo</option>
</select>Shared Links Across Timezones
When sharing filter URLs, the UTC times remain consistent, but display appropriately for each user:
// User A in France creates a filter
const parisDefinitions = [{ id: 'date_range', type: 'date_range', timezone: 'Europe/Paris' }];
const filterString = toFilterString(
{ date_range: { start: '2024-06-15', end: '2024-06-20' } },
parisDefinitions
);
// URL: https://myapp.com/data?filters=date_range:2024-06-14T22:00:00Z,2024-06-20T21:59:59Z
// User B in NYC clicks the same link
const nycDefinitions = [{ id: 'date_range', type: 'date_range', timezone: 'America/New_York' }];
const parsed = parseFilters(filterString, nycDefinitions);
// Same UTC times: { start: 2024-06-14T22:00:00Z, end: 2024-06-20T21:59:59Z }
const displayed = displayDateRange(parsed.date_range, 'America/New_York');
// Displays: { start: '2024-06-14', end: '2024-06-20' }
// NYC user sees June 14-20 (which represents the same moment as Paris June 15-20)
// ✅ Both users query the same UTC range in the database
// ✅ Each sees dates in their local timezone
// ✅ No data discrepanciesReact Hook Example
import { useState, useEffect } from 'react';
import { parseFilters, toFilterString } from '@saasak/utils-filter';
const useFilters = (filterDefinitions: FilterDefinition[]) => {
const [filters, setFilters] = useState({});
// Load from URL on mount
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const filterString = urlParams.get('filters') || '';
const parsed = parseFilters(filterString, filterDefinitions);
setFilters(parsed);
}, []);
// Update URL when filters change
const updateFilters = (newFilters: any) => {
setFilters(newFilters);
const filterString = toFilterString(newFilters, filterDefinitions);
const url = new URL(window.location);
url.searchParams.set('filters', filterString);
window.history.pushState({}, '', url);
};
return [filters, updateFilters] as const;
};Svelte Store Example
// stores/filters.js
import { writable } from 'svelte/store';
import { parseFilters, toFilterString } from '@saasak/utils-filter';
export function createFilterStore(filterDefinitions) {
const { subscribe, set, update } = writable({});
return {
subscribe,
loadFromURL: () => {
const urlParams = new URLSearchParams(window.location.search);
const filterString = urlParams.get('filters') || '';
const parsed = parseFilters(filterString, filterDefinitions);
set(parsed);
},
updateFilter: (filterId, value) => {
update((filters) => {
const newFilters = { ...filters, [filterId]: value };
const filterString = toFilterString(newFilters, filterDefinitions);
const url = new URL(window.location);
url.searchParams.set('filters', filterString);
window.history.pushState({}, '', url);
return newFilters;
});
}
};
}Error Handling
The module gracefully handles invalid inputs:
- Invalid dates return
null - Out-of-range numbers return
null - Invalid enum values are filtered out
- Missing filters return their default values
License
ISC
