<?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: Jung Kim</title>
    <description>The latest articles on DEV Community by Jung Kim (@jungalung).</description>
    <link>https://dev.to/jungalung</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%2F4014036%2F5a66e3b9-dd95-49ba-9f0e-cdc6ddb32c8f.png</url>
      <title>DEV Community: Jung Kim</title>
      <link>https://dev.to/jungalung</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jungalung"/>
    <language>en</language>
    <item>
      <title>Tracking Tableau Server Storage Over Time with PostgreSQL</title>
      <dc:creator>Jung Kim</dc:creator>
      <pubDate>Wed, 29 Jul 2026 11:15:31 +0000</pubDate>
      <link>https://dev.to/jungalung/tracking-tableau-server-storage-over-time-with-postgresql-33fn</link>
      <guid>https://dev.to/jungalung/tracking-tableau-server-storage-over-time-with-postgresql-33fn</guid>
      <description>&lt;p&gt;Tableau's admin views show you storage at a single point in time, but they don't give you a trend line. If you want to know whether your server's footprint is growing, shrinking, or holding steady, you need to snapshot it yourself. Here's the query I run daily to do that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The SQL
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="k"&gt;CURRENT_DATE&lt;/span&gt;                                        &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;snapshot_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ROUND&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;content_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Workbook'&lt;/span&gt;
        &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="k"&gt;size&lt;/span&gt; &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;^&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&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;AS&lt;/span&gt; &lt;span class="n"&gt;workbook_storage_gb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ROUND&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;content_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Datasource'&lt;/span&gt;
        &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="k"&gt;size&lt;/span&gt; &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;^&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&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;AS&lt;/span&gt; &lt;span class="n"&gt;datasource_storage_gb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ROUND&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;size&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;^&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&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;AS&lt;/span&gt; &lt;span class="n"&gt;total_storage_gb&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt;
        &lt;span class="s1"&gt;'Workbook'&lt;/span&gt;                                      &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;content_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;size&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;_workbooks&lt;/span&gt;

    &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;

    &lt;span class="k"&gt;SELECT&lt;/span&gt;
        &lt;span class="s1"&gt;'Datasource'&lt;/span&gt;                                    &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;content_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;size&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                         &lt;span class="c1"&gt;-- one row per datasource, dropping duplicate connection rows&lt;/span&gt;
            &lt;span class="k"&gt;size&lt;/span&gt;
        &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;_datasources&lt;/span&gt;
        &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;                         &lt;span class="c1"&gt;-- deterministic pick when duplicates exist&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;deduped&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The use case
&lt;/h2&gt;

&lt;p&gt;I run this once a day and store the output, turning a single point-in-time snapshot into an actual trend line — workbook storage, datasource storage, and a combined total, all in GB. Over time this becomes the backbone for answering questions like "is our cleanup effort actually reducing our footprint?" or "how fast is storage growing month over month?" — the kind of thing that's easy to ask and impossible to answer from the Tableau UI alone.&lt;/p&gt;

&lt;p&gt;Setup is just publishing this as an extract with &lt;strong&gt;incremental refresh&lt;/strong&gt; turned on, scheduled daily. I cover the mechanics of why that combination works so well — and other metrics you can track the same way — in &lt;a href="https://dev.to/jungalung/the-easiest-way-to-snapshot-repository-data-over-time-4igh"&gt;The Easiest Way to Snapshot Tableau's PostgreSQL Repository Over Time&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Pair this with viewership tracking from my last post and you've got both sides of the story — who's using your content, and how much room it's taking up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things to know before you use this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;_datasources&lt;/code&gt; overcounts without deduplication.&lt;/strong&gt; Each data connection on a multi-connection datasource gets its own row, all carrying the same &lt;code&gt;size&lt;/code&gt; value. Without handling this, a datasource with three connections gets counted three times. The &lt;code&gt;DISTINCT ON (id)&lt;/code&gt; subquery above collapses that back to one row per datasource. Before trusting this in production, run a quick sanity check:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;MIN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;size&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;size&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_datasources&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;HAVING&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;MIN(size)&lt;/code&gt; equals &lt;code&gt;MAX(size)&lt;/code&gt; for every duplicated &lt;code&gt;id&lt;/code&gt;, deduplication is safe as written. If they differ, you'll need to decide which value to trust before summing.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tableau Server uses decimal, not binary, storage units.&lt;/strong&gt; Divide by &lt;code&gt;1000&lt;/code&gt; (not &lt;code&gt;1024&lt;/code&gt;) at each step to convert bytes → KB → MB → GB and match what the Tableau UI itself reports.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>sql</category>
      <category>tableau</category>
      <category>datavisualiszation</category>
    </item>
    <item>
      <title>The Easiest Way to Snapshot Tableau's PostgreSQL Repository Over Time</title>
      <dc:creator>Jung Kim</dc:creator>
      <pubDate>Wed, 15 Jul 2026 08:38:28 +0000</pubDate>
      <link>https://dev.to/jungalung/the-easiest-way-to-snapshot-repository-data-over-time-4igh</link>
      <guid>https://dev.to/jungalung/the-easiest-way-to-snapshot-repository-data-over-time-4igh</guid>
      <description>&lt;p&gt;Tableau's PostgreSQL repository tracks event history well — &lt;code&gt;historical_events&lt;/code&gt; will tell you plenty about what happened and when. But content and state tables like &lt;code&gt;_workbooks&lt;/code&gt; and &lt;code&gt;_datasources&lt;/code&gt; only reflect current state; they don't retain past values for things like storage size or content counts. Getting a trend line out of that might sound like it needs a script, a cron job, or a separate table to accumulate snapshots over time. It doesn't — Tableau Server can do all of that for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Write a SQL query that returns the point-in-time metric you care about (storage, content counts, whatever).&lt;/li&gt;
&lt;li&gt;Publish it to Tableau Server as an extract.&lt;/li&gt;
&lt;li&gt;Set the extract's refresh type to &lt;strong&gt;incremental refresh&lt;/strong&gt;, instead of full refresh.&lt;/li&gt;
&lt;li&gt;Put it on a schedule — daily, weekly, monthly, whatever cadence matches how fast the thing you're tracking actually changes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each scheduled run appends new rows instead of overwriting the extract, so the extract itself becomes your history table. No external script, no cron job, no separate database to maintain — Tableau Server's own scheduler is doing all the work you'd otherwise build yourself. (Tableau's own docs on &lt;a href="https://help.tableau.com/current/pro/desktop/en-us/extracting_refresh.htm#configure-an-incremental-extract-refresh" rel="noopener noreferrer"&gt;configuring incremental refresh&lt;/a&gt; are worth a look if you haven't set one up before.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The use case
&lt;/h2&gt;

&lt;p&gt;I use this to track server storage over time (more on that in another post), but the pattern generalizes to almost any "how has this changed" question you can answer with a single query: content counts, stale content trends, license utilization — anything where you'd otherwise be tempted to stand up a mini data pipeline just to get a time series.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things to know before you use this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Incremental refresh only works cleanly if each run is genuinely additive.&lt;/strong&gt; The query needs to produce new rows each time (e.g., a snapshot keyed to &lt;code&gt;CURRENT_DATE&lt;/code&gt;), not update existing ones. If your query's output could change retroactively for a past date, incremental refresh will get out of sync with reality — full refresh or a different approach is a better fit in that case.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This trades granularity for simplicity.&lt;/strong&gt; You get whatever snapshot frequency your schedule uses — daily is common — not a continuous audit trail of every change as it happens. For most trend-watching use cases that's a fine tradeoff; if you need to catch every individual event, you're back to querying &lt;code&gt;historical_events&lt;/code&gt; directly instead of snapshotting a summary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the query itself simple.&lt;/strong&gt; The whole appeal of this pattern is that Tableau Server does the heavy lifting on scheduling. If the underlying query gets complicated enough that it needs its own maintenance, some of that simplicity advantage starts to erode.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>tableau</category>
      <category>postgres</category>
      <category>dataengineering</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Tracking Tableau Viewership with PostgreSQL</title>
      <dc:creator>Jung Kim</dc:creator>
      <pubDate>Fri, 03 Jul 2026 19:36:23 +0000</pubDate>
      <link>https://dev.to/jungalung/tracking-tableau-viewership-with-postgresql-2l7c</link>
      <guid>https://dev.to/jungalung/tracking-tableau-viewership-with-postgresql-2l7c</guid>
      <description>&lt;h1&gt;
  
  
  Tracking Tableau Viewership with PostgreSQL
&lt;/h1&gt;

&lt;p&gt;Tableau's native "Who has seen this view?" panel is a flat list — no trends, no way to tell if usage is real or just the publisher checking their own work. With direct access to the Tableau PostgreSQL repository, you can do a lot better: turn raw access events into aggregated viewership data your dashboard creators can actually act on. Here's the query I use to power that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The SQL
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;access_events&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="c1"&gt;-- Pull only "Access View" events from the last 90 days&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt;
        &lt;span class="n"&gt;he&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;he&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;he&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hist_view_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;he&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hist_workbook_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;he&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hist_actor_user_id&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;historical_events&lt;/span&gt; &lt;span class="n"&gt;he&lt;/span&gt;
    &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;historical_event_types&lt;/span&gt; &lt;span class="n"&gt;het&lt;/span&gt;
        &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;he&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;historical_event_type_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;het&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;type_id&lt;/span&gt;   &lt;span class="c1"&gt;-- filter to the event type we care about&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;het&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Access View'&lt;/span&gt;
      &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;he&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'90 days'&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;                           &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;workbook_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;-- current workbook name, from the live content table&lt;/span&gt;
    &lt;span class="n"&gt;hu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;friendly_name&lt;/span&gt;                 &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;viewer_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="c1"&gt;-- NULL when the workbook has zero views&lt;/span&gt;
    &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ae&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                     &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;view_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;-- 0 when no matching access events&lt;/span&gt;
    &lt;span class="k"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ae&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;               &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;last_viewed&lt;/span&gt;          &lt;span class="c1"&gt;-- NULL when never viewed&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;_workbooks&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;                                            &lt;span class="c1"&gt;-- drive from the full content table...&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;hist_workbooks&lt;/span&gt; &lt;span class="n"&gt;hw&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;hw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;workbook_id&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;      &lt;span class="c1"&gt;-- ...so workbooks with no history still show up&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;access_events&lt;/span&gt; &lt;span class="n"&gt;ae&lt;/span&gt;  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;ae&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hist_workbook_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;hist_users&lt;/span&gt; &lt;span class="n"&gt;hu&lt;/span&gt;     &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;hu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;               &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ae&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hist_actor_user_id&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;friendly_name&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;view_count&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The use case
&lt;/h2&gt;

&lt;p&gt;I built this as the backbone of a self-service "Viewership Analytics" dashboard — KPI tiles, a time-series trend, top-viewers ranking, and a detail table with recency color-coding. The goal was to give dashboard creators direct visibility into how their own content is actually being used, so they can answer "is anyone using this?" on their own, anytime. Driving the query from the full workbook list also means unviewed content shows up right alongside everything else, instead of needing a second query to catch what never got seen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things to know before you use this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;hist_workbooks.id&lt;/code&gt; is just the internal ID for that snapshot row, not the workbook itself.&lt;/strong&gt; If you need a stable way to track a specific workbook over time, use &lt;code&gt;hist_workbooks.workbook_id&lt;/code&gt; instead. This matters more than it sounds like it should: workbook names and even publishing locations can change during development — especially before a workbook lands in its final, published-to-prod state — so tracking by internal snapshot ID or by name alone can silently split what should be one workbook's history into several.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;friendly_name&lt;/code&gt; availability can vary.&lt;/strong&gt; It's present on &lt;code&gt;hist_users&lt;/code&gt; in this table, but in other parts of the repository it only lives on &lt;code&gt;system_users&lt;/code&gt; — worth confirming which table has it populated in your version before you build on top of this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-views aren't excluded here on purpose.&lt;/strong&gt; As written, this counts a publisher viewing their own workbook the same as anyone else. Depending on your use case that might be exactly what you want (e.g., an admin dashboard), or it might inflate "most viewed" rankings if you're handing this to creators. If you want to exclude them, add a join to the workbook's owner and filter &lt;code&gt;hist_actor_user_id != owner_id&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>sql</category>
      <category>tableau</category>
      <category>datavisualization</category>
    </item>
  </channel>
</rss>
