Back to "Filterbalk Progress (En de datumbereik 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

Filterbalk Progress (En de datumbereik Shenanigans)

Monday, 10 November 2025

Inleiding

In de afgelopen dagen heb ik gehamerd op de nieuwe blog filter bar: taal selectie, bestellen, en een datum bereik picker die heel hard probeert om slim te zijn (soms te slim).

Dit bericht loopt door wat ik gebouwd, waarom een paar dingen ging zijwaarts (data niet gedragen, hoogtepunten niet verfrissend), en hoe ik ze gerepareerd.

Genoeg broncode en een paar zeemeermin diagrammen om de stroom te tonen.

Vergeet niet dat deze site is een werk in uitvoering, dit soort dingen zal gebeuren als je eet-uw-eigen-hond voedsel!

  • Wat is er veranderd?
  • Hier is het hoge niveau: Ik heb een juiste filterbalk toegevoegd aan de blog index die bevat:
  • Taal selecteren die uw huidige filters bewaart en de lijst opnieuw aanvraagt via HTMX
  • Sorteervolgorde selecteren (datum/titel asc/desc) die ook context behoudt
  • Een datumbereikpicker (Flatpickr in range mode) met servergestuurde daghighlights
  • Server caching varieert nu correct door de filterparametersNieuw in deze update (The Big Fix!)
  • Zichtbaarheidsfilters: Posts kunnen nu worden verborgen, gepland voor toekomstige publicatie, of gepind naar de top/blog/date-rangeDatumbereik-eindpunt
  • : NieuwAPI geeft min/max data terug voor verstandige picker grenzen<clear-param>Hulp voor param-tags wissen

: Eenvoudige manier om filters te wissen met behulp van

tag helper met Alpine.js

Onderweg had ik een paar bugs: date ranges verloren gaan op taal/orde verandering, agenda highlights niet verfrissend op swap, en een leuke waar donkere mode stijlen maakte de kalender eruit gebroken omdat Flatpickr was niet her-tekenen.

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

De filterbalk in de DOM

Dit is de ruwe structuur die het script verwacht:Mostlylucid/src/js/blog-index.jsOpdrachtgever: HTMX + Flatpickr bedrading

  • Kern van het gedrag leeft in
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();
  • Een paar belangrijke stukken:
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);
    }
  }
});
  • URL-behoud en HTMX-navigatie
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);
});
  • Initialisatie, standaard van URL, en het bijhouden van de samenvatting UI in synchronisatie
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'] });

Flatpickkr bereik met server-gedreven dag highlights

Bewaar datums bij het wijzigen van taal of volgorde

  • Agenda opnieuw tekenen wanneer het thema aan/uitschakelt (donker/licht) dus Flatpickr-herschilderingen*
  • Server: eindpunten en cachingcalendar-daysDrie eindpunten voeden de pagina:
  • De index zelf, die pagina, paginaSize, startDatum/endDate, taal en volgorde accepteertdate-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")
    });
}

eindpunt dat de reeks dagen in een bepaalde maand die berichten (voor hoogtepunten) teruggeeftLinkUrlA

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; }
}

eindpunt dat de min/max data teruggeeft in alle berichten (taalbewust)

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

Een snelle opzij: het paging model heeft

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]

zodat paginatie de huidige filtercontext behoudt wanneer de server-kant wordt weergegeven.

Sequentie: hoe een filter verandert

Flow: agenda highlightsBlogPostEntity:

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

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

Nieuwe functies: Zichtbaarheid en inhoudscontroleBlogServicePostzichtfilters

// 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);
}

De grootste verbetering in deze update is de juiste post zichtbaarheid management.

  • Er zijn drie nieuwe velden toegevoegd aan (IsHidden = trueDe
  • **nu filtert berichten op de juiste manier:**Dit betekent:ScheduledPublishDate
  • Verborgen posten (IsPinned = true) nooit verschijnen in lijsten

Geplande posten

alleen verschijnen na hun

[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 ...
}

Gepinde posten

<!-- 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>

) verschijnen altijd eerst op pagina 1, perfect voor aankondigingen of aanbevolen inhoudwindow.queryParamClearerParam-taghelper wissen

**Een nieuwe ASP.NET Core tag helper maakt het gemakkelijk om query parameters te wissen:**Gebruik in Razor views:

De tag helper integreert met Alpine.js's

  • component om de URL manipulatie en HTMX swaps te verwerken.

    • OpmerkingstartDate/endDate.
    • : Zowel de paging tag helper als de query parameter duidelijker verdienen diepere exploratie.new URL(window.location.href)Ik zal de volledige implementatie details, waaronder de Alpine.js component en tag helper patronen, in een toekomstige post over het bouwen van herbruikbare HTMX/Alpine componenten.language/orderBugs Ik raakte (en de fixes)
  • Datumbereik verloren op taal/ordeverandering

    • Oorzaak: Ik bouwde de nieuwe URL vanaf nul, niet vanaf de huidige; verloren
    • Fix: Altijd beginnen met
  • en verander alleen de onderdelen die veranderd; of lees dateert van Flatpickr indien aanwezig.

    • Zie de
    • verander van handler boven.<html class>Kalender highlights zijn niet ververst na een HTMX-ruilfp.redraw().
  • Oorzaak: Flatpickr instance leefde op de oude DOM en stierf op ruil.

    • Fix: Vernietig op init alle bestaande instantie, herschep en preload highlights voor de zichtbare maand.LinkUrlOok re-hook na swaps (zie hieronder).
    • Donkere modus maakte de agenda er gebroken uitzienposts.LinkUrl = Url.Action("Index","Blog", new { startDate, endDate, language, orderBy, orderDir })Oorzaak: stijl klassen veranderd maar Flatpickr niet opnieuw geschilderd dagen.

Fix: Observeer

wijzigingen en oproep

(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);
})();
  • Oorzaak:
  • was niet ingesteld om de query in sommige gevallen op te nemen.
  • Fix: Op de server, ingesteld

zodat de pager correct URL's componeert.

logo

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