<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Irrational Apps</title>
    <description>The latest articles on DEV Community by Irrational Apps (@irrationalapps).</description>
    <link>https://dev.to/irrationalapps</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4000858%2F47259199-8fba-404a-88dc-9331fa083e3a.png</url>
      <title>DEV Community: Irrational Apps</title>
      <link>https://dev.to/irrationalapps</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/irrationalapps"/>
    <language>en</language>
    <item>
      <title>Why your generated color palettes look muddy (and what OKLCH does about it)</title>
      <dc:creator>Irrational Apps</dc:creator>
      <pubDate>Tue, 07 Jul 2026 07:30:02 +0000</pubDate>
      <link>https://dev.to/irrationalapps/why-your-generated-color-palettes-look-muddy-and-what-oklch-does-about-it-410c</link>
      <guid>https://dev.to/irrationalapps/why-your-generated-color-palettes-look-muddy-and-what-oklch-does-about-it-410c</guid>
      <description>&lt;p&gt;I've built a handful of small color tools over the past year, and the thing that keeps surprising me is how bad HSL is at the one job people reach for it to do: generating a set of colors that look evenly spaced.&lt;/p&gt;

&lt;p&gt;If you've ever built a palette by walking the hue wheel in HSL, keeping saturation and lightness fixed and stepping the hue, you've probably seen the result. The yellows and greens come out glaring, the blues and purples look dark and muddy, and the whole set feels lopsided even though the numbers insist every color sits at "50% lightness."&lt;/p&gt;

&lt;p&gt;That isn't your monitor or your eyes. It's HSL.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why HSL lightness lies to you
&lt;/h2&gt;

&lt;p&gt;HSL's L channel is a geometric construct, not a perceptual one. It's derived from the max and min of the RGB channels, which has almost nothing to do with how bright a color actually looks to a person. Yellow at L=50% looks far brighter than blue at L=50%, because human vision is much more sensitive to the green-ish wavelengths that dominate yellow than to blue.&lt;/p&gt;

&lt;p&gt;So when you generate N colors at fixed S and L with evenly spaced hue, perceived brightness swings wildly as you go around the wheel. For data visualization this is actively harmful. The bright categories jump forward and the dark ones recede, which implies an importance ranking that your data never had.&lt;/p&gt;

&lt;h2&gt;
  
  
  What OKLCH changes
&lt;/h2&gt;

&lt;p&gt;OKLCH is a cylindrical form of the OKLab color space. It keeps the same three knobs you already know from HSL (a lightness, a chroma or "colorfulness", and a hue angle), but it's built on a model of human vision instead of raw RGB arithmetic.&lt;/p&gt;

&lt;p&gt;L is perceptual lightness, so two colors at the same L genuinely look equally bright no matter their hue. C is chroma, roughly how vivid the color is. H is the hue angle in degrees.&lt;/p&gt;

&lt;p&gt;The payoff is simple. Hold L and C steady, sweep H, and the palette reads as evenly weighted. Nothing leaps out, nothing disappears. And because browsers now support the &lt;code&gt;oklch()&lt;/code&gt; function in CSS natively, you can take those values straight into a stylesheet without converting anything.&lt;/p&gt;

&lt;p&gt;Here's the same idea in code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// evenly spaced, perceptually uniform hues&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;oklchPalette&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.13&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="mi"&gt;360&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`oklch(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Swap that same loop into HSL and the output looks uneven no matter how you tune it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two gotchas worth knowing
&lt;/h2&gt;

&lt;p&gt;OKLCH describes a larger space than sRGB, so not every (L, C, H) triple maps to a real screen color. Push chroma too high at certain hues and you fall outside the gamut, the value gets clamped, and your carefully balanced palette quietly flattens in a couple of spots. Keeping chroma moderate, or letting your tool clamp per hue, avoids it.&lt;/p&gt;

&lt;p&gt;Also worth saying plainly: "perceptually uniform" is not the same as "pretty." Uniform brightness is exactly what you want for categorical data viz where every series is equal. For a UI accent ramp you usually want the opposite, deliberate brightness steps from light to dark.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tool I ended up building
&lt;/h2&gt;

&lt;p&gt;I folded all of this into a generator: &lt;a href="https://irrationaltools.com/color-palette-generator" rel="noopener noreferrer"&gt;https://irrationaltools.com/color-palette-generator&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It has three modes, because I kept needing different things on different days:&lt;/p&gt;

&lt;p&gt;Distinct gives you N maximally distinguishable colors at uniform perceived brightness. This is the data-viz case, up to 30 colors, with 23 presets if you want a starting mood (Pastel, Ocean, Corporate, Medical, and so on).&lt;/p&gt;

&lt;p&gt;Sequential builds multi-stop gradients with a choice of interpolation space and hue path (shortest, longest, clockwise, counter-clockwise around the wheel), plus a one-click CSS gradient export you can paste straight into a stylesheet.&lt;/p&gt;

&lt;p&gt;From Image lets you upload a picture and pulls a palette out of it using k-means clustering, or you can click a single pixel to anchor a harmonic palette (complementary, triadic, analogous) off that color.&lt;/p&gt;

&lt;p&gt;It runs entirely in the browser, nothing gets uploaded, there's no login, and you can copy any swatch as HEX, RGB, HSL, or OKLCH. It's free.&lt;/p&gt;

&lt;p&gt;If you take one thing from this, let it be this: stop generating categorical palettes in HSL. Even if you never touch my tool, switch that loop to OKLCH and your charts will look a lot more honest.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>css</category>
      <category>design</category>
      <category>colors</category>
    </item>
    <item>
      <title>Building a color replacer: why RGB matching fails on photos and how HSV fixes it</title>
      <dc:creator>Irrational Apps</dc:creator>
      <pubDate>Wed, 01 Jul 2026 08:38:17 +0000</pubDate>
      <link>https://dev.to/irrationalapps/building-a-color-replacer-why-rgb-matching-fails-on-photos-and-how-hsv-fixes-it-1n8</link>
      <guid>https://dev.to/irrationalapps/building-a-color-replacer-why-rgb-matching-fails-on-photos-and-how-hsv-fixes-it-1n8</guid>
      <description>&lt;p&gt;I've been building browser-based image tools for a while now, and color replacement is one of those features that seems simple on the surface but hides a lot of interesting problems. I want to write about the main one: why the obvious approach (RGB distance matching) fails on real photos, and how HSV fixes it. Plus some notes on shading preservation, edge detection, and where intelligent scissors fit in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why RGB matching fails on photos
&lt;/h2&gt;

&lt;p&gt;When most developers implement "replace this color," the first instinct is something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dist&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;r0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;g0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dist&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;replacePixel&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works for flat, uniform colors on a white background. It breaks completely on photos with lighting variation.&lt;/p&gt;

&lt;p&gt;The problem is shadows. A red shirt in bright light might have pixels around (220, 40, 40). The same shirt in shadow is more like (80, 15, 15). Both are "red." Their Euclidean distance in RGB space is massive, so either you set a high threshold and accidentally replace dark brown/maroon pixels you didn't want, or you set a low threshold and miss everything in the shadows.&lt;/p&gt;

&lt;p&gt;You end up in a losing tradeoff that doesn't resolve cleanly no matter how much you fiddle with the slider.&lt;/p&gt;

&lt;h2&gt;
  
  
  Switching to HSV
&lt;/h2&gt;

&lt;p&gt;HSV separates the color information (hue) from the brightness (value). That red shirt in shadow still has a hue of around 0 degrees regardless of how dark the pixel is. So you can match on hue with a tight tolerance and not worry about lighting variation at all.&lt;/p&gt;

&lt;p&gt;In practice: convert each pixel to HSV, check if the hue falls within N degrees of your target hue AND saturation exceeds a minimum threshold (this excludes near-white and near-gray pixels where hue is essentially undefined). Both conditions met? That's a pixel to replace.&lt;/p&gt;

&lt;p&gt;The result on photos is significantly better. Deep shadows and bright highlights of the same hue all get picked up, and the selection boundary follows the actual color rather than the lighting.&lt;/p&gt;

&lt;p&gt;One thing that took some iteration: a single "tolerance" slider doesn't work well for HSV. You really want separate H, S, and V tolerance controls. A common case is wanting a tight hue band (only this shade of red, nothing pink or orange) but a wide value tolerance (catch all the dark and light versions of it). Collapsing that into one number forces a tradeoff that's impossible to tune correctly. Three independent sliders is more UI but actually usable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shading preservation
&lt;/h2&gt;

&lt;p&gt;Once you correctly identify the pixels to replace, there's a second problem: how do you fill them?&lt;/p&gt;

&lt;p&gt;Naive approach: overwrite with the new color's RGB values. This makes it look fake instantly. A red jacket replaced with flat blue loses all the depth. The folds, creases, and fabric texture disappear into a uniform blob.&lt;/p&gt;

&lt;p&gt;What actually looks good: replace only the hue component and keep the original pixel's saturation and value. In code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// target hue comes from the new color&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resultH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;targetHSV&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resultS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;originalHSV&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// keep original&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resultV&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;originalHSV&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// keep original&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This preserves all the lighting information: shadows still dark, highlights still bright, texture intact. Only the color itself changes. The output looks like someone actually repainted the object rather than pasted a layer mask over it.&lt;/p&gt;

&lt;p&gt;You can add an optional saturation scale on top of this if the new color should be more or less vivid than the original, but the core keep-S-and-V trick does most of the work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Polygon selection and edge snapping
&lt;/h2&gt;

&lt;p&gt;Global color replace is fine when the background is clearly a different color. But in most real photos, background pixels share hue values with the foreground. You need region selection.&lt;/p&gt;

&lt;p&gt;We implemented a polygon selection tool where the user clicks to define a boundary around the area to recolor. That's straightforward. The interesting part is the magnetic lasso behavior: when the user clicks two anchor points, instead of a straight-line segment between them, the path finder uses edge detection to trace along the nearest object boundary.&lt;/p&gt;

&lt;p&gt;The edge detection is a Sobel operator, two 3x3 convolution kernels that compute intensity gradients in X and Y, then combined as gradient magnitude. High gradient = edge. These values become the traversal cost for a shortest-path search.&lt;/p&gt;

&lt;p&gt;The path finding itself (Livewire / intelligent scissors) is Dijkstra's algorithm on the pixel grid, where traversal cost is inversely proportional to edge strength. Following a strong edge is cheap; cutting across flat regions is expensive. So the algorithm naturally snaps to object boundaries. When it works well (objects with distinct edges against a contrasting background) it feels like the cursor is magnetic, which makes precise selection much faster than clicking every vertex manually.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where we shipped this
&lt;/h2&gt;

&lt;p&gt;All of this is in a free browser tool at &lt;a href="https://irrationaltools.com/color-replacer" rel="noopener noreferrer"&gt;https://irrationaltools.com/color-replacer&lt;/a&gt;. No uploads, no server processing, everything runs in-browser. There's also support for multiple color pairs in one pass (change red to blue AND green to orange simultaneously), full undo/redo, and zoom up to 10x for pixel-level work.&lt;/p&gt;

&lt;p&gt;If you're implementing something similar and have questions on the HSV matching, the Livewire implementation, or anything else in here, happy to dig into it in the comments.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>programming</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Exception-based attendance: stop storing a row for every student who showed up</title>
      <dc:creator>Irrational Apps</dc:creator>
      <pubDate>Wed, 24 Jun 2026 18:26:34 +0000</pubDate>
      <link>https://dev.to/irrationalapps/exception-based-attendance-stop-storing-a-row-for-every-student-who-showed-up-9c2</link>
      <guid>https://dev.to/irrationalapps/exception-based-attendance-stop-storing-a-row-for-every-student-who-showed-up-9c2</guid>
      <description>&lt;p&gt;When you build any kind of attendance or check-in feature, the obvious data model is one row per person per session: &lt;code&gt;(session_id, student_id, status)&lt;/code&gt;. Mark everyone, store everyone. It works on day one and quietly becomes a problem by month three.&lt;/p&gt;

&lt;p&gt;For a class of 30 students meeting 20 times a month, that's 600 rows a month — and ~95% of them say the same thing: &lt;strong&gt;present&lt;/strong&gt;. You're paying storage, write cost, and UI friction to record the thing that almost always happens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model the exception, not the norm
&lt;/h2&gt;

&lt;p&gt;The fix is to flip the default. &lt;em&gt;Present&lt;/em&gt; is assumed. You only store the deviations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Attendance exception: (session_id, student_id, status)
  where status ∈ { absent, late, excused }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anyone enrolled in the batch for that session who has &lt;strong&gt;no&lt;/strong&gt; exception row is present. To reconstruct attendance for a session you compute:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;present(session) = roster(session.batch, session.date) − exceptions(session)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The wins are immediate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Storage collapses.&lt;/strong&gt; Two kids skipped class? Two rows. Not thirty.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Marking is fast.&lt;/strong&gt; The tutor taps the 2 absentees instead of confirming 30 names. In practice this is the difference between a 10-second task and a tedious one nobody keeps up with.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The UI gets simpler.&lt;/strong&gt; Default state is "all present," and you only surface what changed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The part people get wrong
&lt;/h2&gt;

&lt;p&gt;The catch is &lt;code&gt;roster(batch, date)&lt;/code&gt;. You can't just use "current members," because membership changes over time — students join mid-term, pause for a vacation, or leave. If you resolve the roster from today's membership, your historical attendance silently rewrites itself.&lt;/p&gt;

&lt;p&gt;So membership needs to be &lt;strong&gt;temporal&lt;/strong&gt;, not a boolean. Store the lifecycle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;membership: (student_id, batch_id, joined_on, events[])
  events: [{ type: paused|resumed|left, on: date }]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;roster(batch, date)&lt;/code&gt; means "students whose membership was active on that date" — and a student paused for February correctly drops out of February's sessions without deleting anything. Attendance from six months ago still reconstructs exactly as it happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters even more offline-first
&lt;/h2&gt;

&lt;p&gt;I ran into this building &lt;a href="https://mentorbatch.com" rel="noopener noreferrer"&gt;Mentor Batch&lt;/a&gt;, a coaching-center manager for Indian tutors (Flutter + Firestore, offline-first so it works in areas with flaky connectivity). When your writes have to sync over a spotty connection, &lt;em&gt;not&lt;/em&gt; writing 28 redundant "present" records per session per day isn't a nice-to-have — fewer writes means less to sync, fewer conflicts, and lower Firestore cost. Modeling the exception paid off twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same idea shows up in billing
&lt;/h2&gt;

&lt;p&gt;Once you start looking for "store the deviation, derive the norm," you see it elsewhere. Mentor Batch's fee ledger uses a cousin of this idea for payments: instead of asking the tutor to mark each month paid, you record a single lump-sum payment and &lt;strong&gt;auto-allocate it oldest-charge-first&lt;/strong&gt; across outstanding dues, generating the per-month breakdown deterministically. The source of truth is the charges and the payments; the "what's paid" view is derived. Same principle — keep the inputs minimal and canonical, compute the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Before you add a status column for every entity in every period, ask which value is the default. If one value dominates, don't store it — store the exceptions and derive the rest from a canonical, time-aware source. Your tables stay small, your writes stay cheap, and your history stays honest.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you run a coaching center or tutor on the side, &lt;a href="https://mentorbatch.com" rel="noopener noreferrer"&gt;Mentor Batch&lt;/a&gt; is free for up to 3 batches and 15 students (&lt;a href="https://play.google.com/store/apps/details?id=com.mentorbatch.app" rel="noopener noreferrer"&gt;Android&lt;/a&gt; or web) — but the attendance pattern above is yours to steal regardless of what you build with.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>flutter</category>
      <category>firebase</category>
      <category>database</category>
    </item>
  </channel>
</rss>
