Back to "Progreso de la barra de filtro (y el rango de fechas Shenanigans)"

This is a viewer only at the moment see the article on how this works.

To update the preview hit Ctrl-Alt-R (or ⌘-Alt-R on Mac) or Enter to refresh. The Save icon lets you save the markdown file to disk

This is a preview from the server running through my markdig pipeline

ASP.NET HTMX JavaScript

Progreso de la barra de filtro (y el rango de fechas Shenanigans)

Monday, 10 November 2025

Introducción

En los últimos días he estado martillando en la nueva barra de filtros de blog: selección de idioma, pedidos y un selector de rango de fechas que trata muy duro de ser inteligente (a veces demasiado inteligente).

Este post camina a través de lo que construí, por qué algunas cosas fueron de lado (las fechas no se comportan, destaca no refrescante), y cómo las arreglé.

Un montón de código fuente y un par de diagramas de sirena para mostrar el flujo.

Recuerde que este sitio es un trabajo en progreso, este tipo de cosas va a suceder cuando usted come-su-propio-perro-alimento!

  • ¿ Qué cambió?
  • Aquí está el de alto nivel: He añadido una barra de filtro adecuada al índice del blog que incluye:
  • Seleccione un idioma que conserve sus filtros actuales y vuelva a solicitar la lista a través de HTMX
  • Ordenar orden seleccionar (fecha/título asc/desc) que también preserva contexto
  • Un selector de rango de fechas (Flatpicker en modo de rango) con puntos destacados del día impulsados por el servidor

El estado URL siempre se mantiene sincronizado (trabajos hacia atrás/adelante, trabajo de enlaces profundos)

  • El almacenamiento en caché del servidor ahora varía correctamente según los parámetros del filtroNuevo en esta actualización (El gran arreglo!)
  • Filtros de visibilidad: Los posts ahora pueden ser ocultos, programados para su futura publicación, o colocados en la parte superior/blog/date-rangePunto final del intervalo de fechas
  • : NuevoAPI devuelve fechas de min/max para límites de recolectores sensibles<clear-param>Borrar param tag helper

: Fácil manera de limpiar los filtros usando

tag helper con Alpine.js

A lo largo del camino tuve algunos errores: rangos de fechas perdidos en el cambio de idioma/orden, resalta calendario no refrescante en el intercambio, y uno divertido donde los estilos de modo oscuro hicieron que el calendario se viera roto porque Flatpicker no estaba redibujando.

<div id="filters" hx-target="#content">
  <select id="languageSelect">…</select>
  <select id="orderSelect">
    <option value="date_desc">Newest first</option>
    <option value="date_asc">Oldest first</option>
    <option value="title_asc">Title A–Z</option>
    <option value="title_desc">Title Z–A</option>
  </select>
  <input id="dateRange" type="text" placeholder="YYYY-MM-DD → YYYY-MM-DD" />
  <button id="clearDateFilter">Clear</button>
  <div id="filterSummary"></div>
</div>

<div id="content"><!-- HTMX swaps blog list here --></div>

La barra de filtro en el DOM

Esta es la estructura aproximada que el script espera:Mostlylucid/src/js/blog-index.jsCliente: HTMX + cableado Flatpicker

  • Núcleo de la conducta vive en
function applyNavigation(u){
  const target = document.querySelector('#content');
  try{ window.history.pushState({}, '', u.toString()); }catch{}
  if(window.htmx && target){
    window.htmx.ajax('GET', u.toString(), {
      target: '#content',
      swap: 'outerHTML show:none',
      headers: {'pagerequest': 'true'}
    });
  } else {
    window.location.href = u.toString();
  }
}
const url = new URL(window.location.href);
const existingStart = url.searchParams.get('startDate');
const existingEnd   = url.searchParams.get('endDate');
const existingLang  = url.searchParams.get('language') || 'en';
const existingOrderBy = (url.searchParams.get('orderBy') || 'date').toLowerCase();
const existingOrderDir = (url.searchParams.get('orderDir') || 'desc').toLowerCase();

langSelect.value = existingLang;
orderSelect.value = `${existingOrderBy}_${existingOrderDir}`;
updateSummary();
  • Algunas piezas importantes:
async function fetchMonth(year, month, language){
  const res = await fetch(`/blog/calendar-days?year=${year}&month=${month}&language=${encodeURIComponent(language||'en')}`);
  if(!res.ok) return new Set();
  const j = await res.json();
  return new Set(j.dates || []);
}

function formatYMD(d){ return d.toISOString().substring(0,10); }

let highlightDates = new Set();
const fp = window.flatpickr(input, {
  mode: 'range',
  dateFormat: 'Y-m-d',
  defaultDate: [existingStart, existingEnd].filter(Boolean),
  onDayCreate: function(_dObj,_dStr,fpInstance,dayElem){
    const ymd = formatYMD(dayElem.dateObj);
    if(highlightDates.has(ymd)){
      dayElem.classList.add('has-post');
      dayElem.style.background = 'rgba(76,175,80,0.35)';
      dayElem.style.borderRadius = '6px';
    }
  },
  onMonthChange: async function(_sd,_ds,fpInstance){
    highlightDates = await fetchMonth(fpInstance.currentYear, fpInstance.currentMonth+1, langSelect.value);
    fpInstance.redraw();
  },
  onOpen: async function(_sd,_ds,fpInstance){
    highlightDates = await fetchMonth(fpInstance.currentYear, fpInstance.currentMonth+1, langSelect.value);
    fpInstance.redraw();
  },
  onChange: function(selectedDates){
    if(selectedDates.length === 2){
      const [start,end] = selectedDates;
      const u = new URL(window.location.href);
      u.searchParams.set('startDate', formatYMD(start));
      u.searchParams.set('endDate', formatYMD(end));
      u.searchParams.set('page','1');
      u.searchParams.set('language', langSelect.value);
      const [ob,od] = orderSelect.value.split('_');
      u.searchParams.set('orderBy', ob);
      u.searchParams.set('orderDir', od);
      updateSummary();
      applyNavigation(u);
    }
  }
});
  • Conservación de URL y navegación HTMX
langSelect.addEventListener('change', async function(){
  const u = new URL(window.location.href);
  u.searchParams.set('language', langSelect.value);
  u.searchParams.set('page', '1');
  const [ob,od] = (orderSelect.value||'date_desc').split('_');
  u.searchParams.set('orderBy', ob);
  u.searchParams.set('orderDir', od);
  if(input._flatpickr && input._flatpickr.selectedDates.length===2){
    const [s,e] = input._flatpickr.selectedDates;
    u.searchParams.set('startDate', formatYMD(s));
    u.searchParams.set('endDate', formatYMD(e));
  }
  // refresh calendar highlights for new language
  if(input._flatpickr){
    const fp = input._flatpickr;
    highlightDates = await fetchMonth(fp.currentYear, fp.currentMonth+1, langSelect.value);
    fp.redraw();
  }
  updateSummary();
  applyNavigation(u);
});
  • Inicialización, predefinición de URL y sincronización de la interfaz de usuario de resumen
const obs = new MutationObserver(() => {
  const dr = root.querySelector('#dateRange');
  const fp = dr && dr._flatpickr;
  if(fp && typeof fp.redraw === 'function') fp.redraw();
});
obs.observe(document.documentElement, { attributes:true, attributeFilter:['class'] });

Gama Flatpicker con puntos destacados del día impulsados por el servidor

Preservar fechas al cambiar de idioma u orden

  • Redibuja el calendario cuando el tema cambia (oscuridad/luz) para que Flatpicker vuelva a pintar*
  • Servidor: puntos finales y almacenamiento en cachécalendar-daysTres puntos finales potencian la página:
  • El índice en sí mismo, que acepta página, tamaño de página, startDate/endDate, idioma y ordendate-rangeA
// GET /blog
[HttpGet]
[ResponseCache(Duration = 300, VaryByHeader = "hx-request",
  VaryByQueryKeys = new[] { "page", "pageSize", nameof(startDate), nameof(endDate), nameof(language), nameof(orderBy), nameof(orderDir) },
  Location = ResponseCacheLocation.Any)]
[OutputCache(Duration = 3600, VaryByHeaderNames = new[] { "hx-request" },
  VaryByQueryKeys = new[] { nameof(page), nameof(pageSize), nameof(startDate), nameof(endDate), nameof(language), nameof(orderBy), nameof(orderDir) })]
public async Task<IActionResult> Index(int page = 1, int pageSize = 20, DateTime? startDate = null, DateTime? endDate = null,
  string language = MarkdownBaseService.EnglishLanguage, string orderBy = "date", string orderDir = "desc")
{
    var posts = await blogViewService.GetPagedPosts(page, pageSize, language: language, startDate: startDate, endDate: endDate);
    posts.LinkUrl = Url.Action("Index", "Blog", new { startDate, endDate, language, orderBy, orderDir });
    if (Request.IsHtmx()) return PartialView("_BlogSummaryList", posts);
    return View("Index", posts);
}

// GET /blog/calendar-days?year=2025&month=11&language=en
[HttpGet("calendar-days")]
[ResponseCache(Duration = 300, VaryByHeader = "hx-request", VaryByQueryKeys = new[] { nameof(year), nameof(month), nameof(language) }, Location = ResponseCacheLocation.Any)]
[OutputCache(Duration = 1800, VaryByHeaderNames = new[] { "hx-request" }, VaryByQueryKeys = new[] { nameof(year), nameof(month), nameof(language) })]
public async Task<IActionResult> CalendarDays(int year, int month, string language = MarkdownBaseService.EnglishLanguage)
{
    if (year < 2000 || month < 1 || month > 12) return BadRequest("Invalid year or month");
    var start = new DateTime(year, month, 1);
    var end = start.AddMonths(1).AddDays(-1);
    var posts = await blogViewService.GetPostsForRange(start, end, language: language);
    if (posts is null) return Json("");
    var dates = posts.Select(p => p.PublishedDate.Date).Distinct().OrderBy(d => d).Select(d => d.ToString("yyyy-MM-dd")).ToList();
    return Json(new { dates });
}

// GET /blog/date-range?language=en
[HttpGet("date-range")]
[ResponseCache(Duration = 3600, VaryByHeader = "hx-request", VaryByQueryKeys = new[] { nameof(language) }, Location = ResponseCacheLocation.Any)]
[OutputCache(Duration = 7200, VaryByHeaderNames = new[] { "hx-request" }, VaryByQueryKeys = new[] { nameof(language) })]
public async Task<IActionResult> DateRange(string language = MarkdownBaseService.EnglishLanguage)
{
    var allPosts = await blogViewService.GetAllPosts();
    if (allPosts is null || !allPosts.Any())
    {
        return Json(new
        {
            minDate = DateTime.UtcNow.AddYears(-1).ToString("yyyy-MM-dd"),
            maxDate = DateTime.UtcNow.ToString("yyyy-MM-dd")
        });
    }

    var posts = allPosts;
    if (!string.IsNullOrEmpty(language) && language != MarkdownBaseService.EnglishLanguage)
    {
        posts = allPosts.Where(p => p.Language.Equals(language, StringComparison.OrdinalIgnoreCase)).ToList();
    }

    if (!posts.Any()) posts = allPosts;

    var minDate = posts.Min(p => p.PublishedDate.Date);
    var maxDate = posts.Max(p => p.PublishedDate.Date);

    return Json(new
    {
        minDate = minDate.ToString("yyyy-MM-dd"),
        maxDate = maxDate.ToString("yyyy-MM-dd")
    });
}

endpoint que devuelve el conjunto de días en un mes dado que tienen mensajes (para los puntos destacados)LinkUrlA

public class BasePagingModel<T> : Interfaces.IPagingModel<T> where T : class
{
    public int Page { get; set; }
    public int TotalItems { get; set; } = 0;
    public int PageSize { get; set; }
    public ViewType ViewType { get; set; } = ViewType.TailwindAndDaisy;
    public string LinkUrl { get; set; }
    public List<T> Data { get; set; }
}

endpoint que devuelve las fechas de min/max en todos los mensajes (consciente de idioma)

sequenceDiagram
  participant U as User
  participant FP as Flatpickr
  participant JS as blog-index.js
  participant HT as HTMX
  participant C as BlogController

  U->>FP: Selects 2025-11-01 → 2025-11-10
  FP-->>JS: onChange([start,end])
  JS->>JS: Update URLSearchParams (startDate, endDate, language, order)
  JS->>HT: htmx.ajax('GET', /blog?...)
  HT->>C: GET /blog with query
  C-->>HT: Partial _BlogSummaryList
  HT-->>U: Swap #content
  JS->>FP: (after swap) ensure highlights + redraw

Un rápido a un lado: el modelo de paginación tiene

flowchart TD
  A[Open calendar / Month change] --> B[Fetch /blog/calendar-days]
  B -->|JSON dates: yyyy-mm-dd array| C[Set highlightDates]
  C --> D[flatpickr redraw]
  D --> E[onDayCreate adds .has-post]

para que la paginación preserve el contexto actual del filtro cuando se muestra en el lado del servidor.

Secuencia: cómo fluye un cambio de filtro

Flujo: elementos destacados del calendarioBlogPostEntity:

public class BlogPostEntity
{
    // ... existing fields ...

    public bool IsPinned { get; set; }
    public bool IsHidden { get; set; }
    public DateTimeOffset? ScheduledPublishDate { get; set; }
}

Nuevas características: Visibilidad y control de contenidoBlogServiceFiltros de visibilidad de postes

// Filter out hidden posts and posts scheduled for the future
var now = DateTimeOffset.UtcNow;
postQuery = postQuery.Where(x =>
    !x.IsHidden &&
    (x.ScheduledPublishDate == null || x.ScheduledPublishDate <= now));

// For page 1, prioritize pinned posts
var isFirstPage = page == null || page.Value == 1;
if (isFirstPage)
{
    postQuery = postQuery.OrderByDescending(x => x.IsPinned)
                         .ThenByDescending(x => x.PublishedDate.DateTime);
}

La mayor mejora en esta actualización es la gestión adecuada de la visibilidad post.

  • Se añadieron tres nuevos campos a (IsHidden = trueLos
  • **ahora filtra los mensajes adecuadamente:**Esto significa:ScheduledPublishDate
  • Puestos ocultos (IsPinned = true) nunca aparecen en los listados

Puestos previstos

sólo aparecen después de su

[HtmlTargetElement("clear-param")]
public class ClearParamTagHelper : TagHelper
{
    [HtmlAttributeName("name")]
    public string? Name { get; set; }

    [HtmlAttributeName("all")]
    public bool All { get; set; } = false;

    [HtmlAttributeName("exclude")]
    public string Exclude { get; set; } = "";

    // ... styling and Alpine.js integration ...
}

Puestos fijados

<!-- Clear a specific parameter -->
<clear-param name="startDate">Clear Date</clear-param>

<!-- Clear all parameters except language -->
<clear-param all="true" exclude="language">Clear All Filters</clear-param>

) siempre aparecen primero en la página 1, perfecto para anuncios o contenido destacadowindow.queryParamClearerBorrar ayudante de etiqueta de Param

**Un nuevo ayudante de etiquetas ASP.NET Core facilita la limpieza de los parámetros de consulta:**Uso en vistas a Razor:

El ayudante de la etiqueta se integra con Alpine.js's

  • componente para manejar la manipulación de URL y swaps HTMX.

    • NotastartDate/endDate.
    • : Tanto el ayudante de etiquetas de paginación como el parámetro de consulta más claro merecen una exploración más profunda.new URL(window.location.href)Cubriré los detalles completos de la implementación, incluyendo el componente Alpine.js y los patrones de ayuda de etiquetas, en un post futuro sobre la construcción de componentes reutilizables HTMX/Alpine.language/orderErrores que golpeo (y las correcciones)
  • Rango de fechas perdido en el cambio de idioma/orden

    • Causa: Construí la nueva URL desde cero, no desde la actual; perdido
    • Arreglar: Siempre empezar con
  • y solo cambie las partes que cambiaron; o lea las fechas de Flatpicker si está presente.

    • Ver la
    • cambiar los manejadores de arriba.<html class>Los aspectos más destacados del calendario no se actualizaron después de un intercambio de HTMXfp.redraw().
  • Causa: Flatpicker instancia vivió en el viejo DOM y murió en el intercambio.

    • Arreglar: En init, destruir cualquier instancia existente, volver a crear y, a continuación, precargar puntos destacados para el mes visible.LinkUrlTambién volver a enganchar después de los swaps (véase más adelante).
    • El modo oscuro hizo que el calendario pareciera rotoposts.LinkUrl = Url.Action("Index","Blog", new { startDate, endDate, language, orderBy, orderDir })Causa: las clases de estilo cambiaron pero Flatpicker no redibujó los días pintados.

Fijar: Observar

cambios y llamada

(function(){
  function initFromRoot(root){ /* …the big function shown above… */ }
  if(window.htmx){
    document.body.addEventListener('htmx:afterSwap', function(ev){
      const tgt = ev.detail.target;
      // re-init if the content swapped includes the filters/content area
      if(tgt && (tgt.id === 'content' || tgt.querySelector?.('#filters'))){
        initFromRoot(document);
      }
    });
  }
  // also run on first load
  initFromRoot(document);
})();

Contexto de filtro caído de enlaces de paginación

  • Causa:
  • no estaba configurado para incluir la consulta en algunos casos.
  • Corregir: En el servidor, establecer

para que el paginador componga correctamente las URLs.

logo

© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.