npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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%7D

After (utils-filter):

?filters=category%3Aproducts%7Cprice%3A10%2C100%7CisActive%3Atrue

Features

  • 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_TRUNC queries
  • 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-filter

Basic 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, checked
  • false: 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 string
  • filterDefinitions: 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 values
  • filterDefinitions: 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 queries
  • value: Object containing the filter value and optional timezone information
  • options: 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 filter
  • price:10 - Number
  • tags:red,blue,green - Array of values
  • isActive:true - Boolean filter
  • createdDate:2024-01-15T10:30:00.000Z - Date filter
  • dateRange: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 timezone
  • toStartOfDay() - Truncate to start of day
  • toStartOfWeek() - Truncate to start of week
  • toStartOfMonth() - Truncate to start of month
  • toStartOfYear() - 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 hour

Real-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 timezone

Svelte 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 discrepancies

React 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