fuzzyworld

fuzzyworld-tools / commit

Code and apps indexes: tables, ordered by newest code commit (Q109)

392edf540107fa5e6b8ce8c6954a0ad0f670e043 · Pavel Safronov · 2026-11-04 10:32:20 -0800 · parents 799905aed051

Claude Code usage: this commit 14.5K out / 1.2M cached (first commit of its session: everything since it began) · est. $0.64 (API list-price equivalent at 2026-10-22 rates, not what was paid) · session 14 tally 62 in / 14.5K out / 1.2M cached · usage by commit and session

Code and apps indexes: tables, ordered by newest code commit (Q109)

Both indexes were tiles; they are now tables with a description column. The apps
index is ordered by the newest commit of each app's repo, not by deploy date (apps
are redeployed often without code changes); the deploy date has its own column.
"Last commit" is blank when an app's repo is unreadable. Date cells use td.num so
the phone rule that hides .log td.date cannot misalign the headers.

FW-Tokens-In: 62
FW-Tokens-Out: 14536
FW-Tokens-Cached: 1222044
FW-Tokens-Method: 2
FW-Model-Tokens: claude-opus-5-5 in=16 out=3537 read=131188 write5m=23268 write1h=0
FW-Model-Tokens: claude-sonnet-5-5 in=46 out=10999 read=1090856 write5m=0 write1h=50783
FW-Session: b51bf9ed-cdc8-4b22-99a0-da69f374e66e
FW-Rtk-Saved: 4633975
FW-Rtk-Cmds: 14062

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
 PLAN.md               |  2 +-
 README.md             |  2 +-
 server/gen.py         | 56 ++++++++++++++++++++++++++++++---------------------
 server/site/style.css | 50 ++++++++-------------------------------------
 tests/test_gen.py     | 54 +++++++++++++++++++++++++++++++++++++++++++++++++
 5 files changed, 97 insertions(+), 67 deletions(-)

diff --git a/PLAN.md b/PLAN.md
index 7ef68de..2d5c4a4 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -26,7 +26,7 @@ never anything from a repo).
 
 All done: ✅ live since 2026-10-27 (v1.1.0), unless noted.
 
-1. ✅ Apps index: newest deploy first. (It once listed the commits of each deploy; removed 2026-10-27 as noise.)
+1. ✅ Apps index: newest deploy first (2026-11-04: now newest code commit first, and both the code and apps indexes are tables, not tiles). (It once listed the commits of each deploy; removed 2026-10-27 as noise.)
 2. ✅ **LICENSE file** (MIT) in this repo. Each repo's license on its summary page.
 3. ✅ **Principles** in four places: `/why/`, `/how/`, a new `/principles/` page, and a "Philosophy" section in the
    README. `/how/` is still a draft that `install.sh` doesn't install (see Backlog); the principle is in it for
diff --git a/README.md b/README.md
index 32cb444..b4d6b83 100644
--- a/README.md
+++ b/README.md
@@ -78,7 +78,7 @@ Then `pnpm run deploy` / `npm run deploy`. **`fw deploy`**:
 
 `--dry-run` does everything except upload.
 
-The apps index lists the most recently deployed app first, with its description, a screenshot if the runner has taken one, and a source link for apps from public repos.
+The apps index is a table, ordered by the newest commit of each app's repo (not by deploy date, since an app can be redeployed often without its code changing; the deploy date is shown in its own column). Columns: a screenshot thumbnail if the runner has taken one, name, description, last commit, deployed, and a source link for apps from public repos. The code index (code.fuzzyworld.net) is likewise a table of public repos, newest commit first, with description and an app link.
 
 Apps hosted elsewhere are listed on the index from `server/site-apps/extra.json` (engye: Cloudflare Pages; `apps.fuzzyworld.net/engye/...` redirects there).
 
diff --git a/server/gen.py b/server/gen.py
index eb2d4c2..5b7c7ad 100755
--- a/server/gen.py
+++ b/server/gen.py
@@ -1922,25 +1922,32 @@ def build_site_usage():
     print(f"site usage: {len(rows)} repos, {len(buckets)} buckets")
 
 
+def last_commit_time(repo):
+    """Unix time of the newest commit on the repo's HEAD, or 0 (empty or unreadable repo)."""
+    last = git_text(repo, "log", "-1", "--format=%ct", "HEAD", check=False).strip()
+    return int(last) if last.isdigit() else 0
+
+
 def build_index():
+    """code.fuzzyworld.net/index.html: a table of public repos, most recently committed first."""
     build_ci_dashboard()
-    rows = []
-    for name, repo in published_public_repos():
-        last = git_text(repo, "log", "-1", "--format=%cs", "HEAD", check=False).strip()
-        rows.append((last, name, repo_config(repo, "fuzzyworld.description"), app_url(name)))
-    rows.sort(key=lambda r: r[0], reverse=True)
+    rows = [(last_commit_time(repo), name, repo_config(repo, "fuzzyworld.description"), app_url(name))
+            for name, repo in published_public_repos()]
+    rows.sort(key=lambda r: (-r[0], r[1]))
     items = []
-    for last, n, d, a in rows:
-        app = f' · <a href="{esc(a)}" class="icon-text"><span class="icon icon-app"></span>app</a>' if a else ""
-        desc = f'<p class="desc">{esc(d)}</p>' if d else ""
-        items.append(f'<li><a class="name" href="{url(n)}/">{esc(n)}</a>'
-                     f'<span class="meta">updated {esc(last or "never (empty)")}{app}</span>{desc}</li>')
+    for t, n, d, a in rows:
+        app = f'<a href="{esc(a)}" class="icon-text"><span class="icon icon-app"></span>app</a>' if a else ""
+        when = time.strftime("%Y-%m-%d", time.gmtime(t)) if t else "never (empty)"
+        items.append(f'<tr><td><a class="name" href="{url(n)}/">{esc(n)}</a></td><td>{esc(d or "")}</td>'
+                     f'<td class="num">{when}</td><td>{app}</td></tr>')
     body = (f"<h1>Pavel's code</h1>"
             f"<p>Browse, or clone with <code>git clone {BASE_URL}/&lt;repo&gt;.git</code>. No account or sign-in needed. "
             f'Small web apps are at <a href="{APPS_URL}/">apps.fuzzyworld.net</a>. <br /> <br />'
             f'<a href="/usage/">Claude Code usage across all repositories</a><br /> <br />'
             f'<a href="/why-i-left-gitlab/">Why aren\'t those GitHub/GitLab/Codeberg links? (Why I left GitLab)</a></p>'
-            f'<ul class="repos">{"".join(items)}</ul>')
+            f'<p class="meta">Newest commit first.</p>'
+            f'<table class="log repos"><tr><th>Repository</th><th>Description</th><th>Last commit</th><th>App</th></tr>'
+            f'{"".join(items)}</table>')
     write(os.path.join(SITE, "index.html"), page("Repositories", body))
     build_site_usage()
     print(f"index: {len(rows)} public repos")
@@ -1965,8 +1972,7 @@ def build_apps_index():
         # Use commit date from the repo instead of deploy timestamp for consistent sorting
         repo = os.path.join(GIT_ROOT, a["repo"] + ".git")
         if a["repo"] and os.path.isdir(repo):
-            last = git_text(repo, "log", "-1", "--format=%ct", "HEAD", check=False).strip()
-            a["time"] = int(last) if last.isdigit() else 0
+            a["time"] = a["commit_time"] = last_commit_time(repo)
         else:
             # Fallback to deploy timestamp if repo is not accessible
             try:
@@ -1980,26 +1986,30 @@ def build_apps_index():
         with open(extra) as f:
             for a in json.load(f):
                 repo = os.path.join(GIT_ROOT, a.get("repo", "") + ".git")
-                last = git_text(repo, "log", "-1", "--format=%ct", "HEAD", check=False).strip() if a.get("repo") and os.path.isdir(repo) else ""
-                apps.append({**a, "time": int(last) if last.isdigit() else 0})
-    apps.sort(key=lambda a: (-a["time"], a["title"].lower()))
+                t = last_commit_time(repo) if a.get("repo") and os.path.isdir(repo) else 0
+                apps.append({**a, "time": t, "commit_time": t})
+    apps.sort(key=lambda a: (-a["time"], a["title"].lower()))  # newest code commit first, not newest deploy
     items = []
     for a in apps:
         repo = os.path.join(GIT_ROOT, a.get("repo", "") + ".git")
         public = a.get("repo") and os.path.isdir(repo) and is_public(repo)
-        src = f'{" · " if a["time"] else ""}<a href="{BASE_URL}/{urllib.parse.quote(a["repo"])}/" class="icon-text"><span class="icon icon-code"></span>source</a>' if public else ""
-        when = ("updated " + time.strftime("%Y-%m-%d", time.gmtime(a["time"]))) if a["time"] else ""
-        desc = f'<p class="desc">{esc(a["description"])}</p>' if a.get("description") else ""
+        src = f'<a href="{BASE_URL}/{urllib.parse.quote(a["repo"])}/" class="icon-text"><span class="icon icon-code"></span>source</a>' if public else ""
+        # "Last commit" is blank when the repo is unreadable: "time" then holds the deploy time, only for ordering.
+        when = time.strftime("%Y-%m-%d", time.gmtime(a["commit_time"])) if a.get("commit_time") else ""
+        deployed = esc((a.get("deployed") or "")[:10])
         shots = app_shots(a["app"], a["repo"] if public else None) if a.get("app") else []
         thumb = (f'<a class="thumb" href="{esc(a["href"])}"><img src="{esc(shots[0][0])}" alt="" loading="lazy"></a>'
                  if shots else "")
-        cls = ' class="has-shot"' if shots else ""
-        items.append(f'<li{cls}>{thumb}<div><a class="name" href="{esc(a["href"])}">{esc(a["title"])}</a>'
-                     f'<span class="meta">{when}{src}</span>{desc}</div></li>')
+        items.append(f'<tr><td class="shot">{thumb}</td><td><a class="name" href="{esc(a["href"])}">{esc(a["title"])}</a></td>'
+                     f'<td>{esc(a.get("description") or "")}</td><td class="num">{when}</td>'
+                     f'<td class="num">{deployed}</td><td>{src}</td></tr>')
     body = (f"<h1>Pavel's apps</h1>"
             f'<p>Small web apps and games. No accounts, no sign-in. Source code is at '
             f'<a href="{BASE_URL}/">code.fuzzyworld.net</a>.</p>'
-            f'<ul class="repos">{"".join(items)}</ul>')
+            f'<p class="meta">Ordered by the newest code commit, not by deploy date: an app can be redeployed '
+            f'often without its code changing.</p>'
+            f'<table class="log repos"><tr><th></th><th>App</th><th>Description</th><th>Last commit</th>'
+            f'<th>Deployed</th><th>Source</th></tr>{"".join(items)}</table>')
     write(os.path.join(APPS, "index.html"), page("Apps", body, site="apps"))
     print(f"apps index: {len(apps)} apps")
 
diff --git a/server/site/style.css b/server/site/style.css
index d7db260..d40e1de 100644
--- a/server/site/style.css
+++ b/server/site/style.css
@@ -527,46 +527,14 @@ img.blob {
 }
 
 /* =============================================================================
-   Repository/App Cards
+   Repository/App tables (code and apps indexes)
    ============================================================================= */
 
-ul.repos {
-  list-style: none;
-  padding: 0;
-  margin: var(--spacing-md) 0;
-  display: grid;
-  gap: var(--gap-sm);
-  grid-template-columns: repeat(auto-fill, minmax(min(100%, 21rem), 1fr));
-}
-
-ul.repos li {
-  padding: var(--spacing-md) var(--spacing-md);
-  background: var(--site-card);
-  border: 1px solid var(--site-line);
-  border-radius: var(--border-radius-lg);
-  box-shadow: var(--box-shadow);
-}
-
-ul.repos li:hover {
-  border-color: var(--site-link);
-}
-
-ul.repos .name {
+table.repos .name {
   font-weight: var(--font-weight-semibold);
-  font-size: var(--font-size-lg);
   overflow-wrap: anywhere;
 }
 
-ul.repos .meta {
-  display: block;
-  font-size: var(--font-size-xs);
-}
-
-ul.repos .desc {
-  margin: var(--spacing-xs) 0 0;
-  font-size: var(--font-size-sm);
-}
-
 /* Details/summary styling */
 details summary {
   cursor: pointer;
@@ -659,17 +627,15 @@ pre.ci-log {
    ============================================================================= */
 
 /* Screenshots: thumbnails on the apps index, a row on a repo's summary page. */
-ul.repos li.has-shot {
-  display: grid;
-  grid-template-columns: 5.5rem 1fr;
-  gap: var(--gap-sm);
-  align-items: start;
+table.repos td.shot {
+  width: 3.5rem;
+  padding-right: 0;
 }
 
-ul.repos .thumb img {
+table.repos .thumb img {
   display: block;
-  width: 5.5rem;
-  height: 7.5rem;
+  width: 3.5rem;
+  height: 4.75rem;
   object-fit: contain;
   object-position: left top;
   border: 1px solid var(--site-line);
diff --git a/tests/test_gen.py b/tests/test_gen.py
index 360e275..1c163b3 100644
--- a/tests/test_gen.py
+++ b/tests/test_gen.py
@@ -529,6 +529,60 @@ class UsageChartsAndSiteRollup(UsageFixtures, unittest.TestCase):
         self.assertIn('<a href="/usage/">', next(iter(written.values())))
         bsu.assert_called_once()
 
+    def _repo_committed_at(self, root, name, date):
+        """A public bare repo under root whose only commit is dated `date` (ISO)."""
+        work = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, work, True)
+        env = {**os.environ, "GIT_AUTHOR_DATE": date, "GIT_COMMITTER_DATE": date, "GIT_AUTHOR_NAME": "t",
+               "GIT_AUTHOR_EMAIL": "t@x", "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@x"}
+        subprocess.run(["git", "init", "-q", work], check=True)
+        subprocess.run(["git", "-C", work, "commit", "-q", "--allow-empty", "-m", "c"], check=True, env=env)
+        bare = os.path.join(root, name + ".git")
+        subprocess.run(["git", "clone", "-q", "--bare", work, bare], check=True)
+        subprocess.run(["git", "-C", bare, "config", "fuzzyworld.visibility", "public"], check=True)
+        return bare
+
+    def test_indexes_are_tables_ordered_by_newest_commit_not_deploy(self):
+        root, apps = tempfile.mkdtemp(), tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, root, True)
+        self.addCleanup(shutil.rmtree, apps, True)
+        old = self._repo_committed_at(root, "old", "2026-01-01T12:00:00Z")
+        new = self._repo_committed_at(root, "new", "2026-06-01T12:00:00Z")
+        # "old" was deployed most recently, but its code is older: it must still be listed second.
+        for app, deployed in [("old", "2026-11-03T00:00:00Z"), ("new", "2026-02-01T00:00:00Z")]:
+            os.makedirs(os.path.join(apps, app))
+            with open(os.path.join(apps, app, ".fuzzyworld.json"), "w") as f:
+                json.dump({"title": app, "repo": app, "deployed": deployed}, f)
+        written = {}
+        with mock.patch.object(gen, "write", lambda p, c: written.update({p: c})), mock.patch.object(gen, "GIT_ROOT", root), \
+                mock.patch.object(gen, "APPS", apps), mock.patch.object(gen, "build_ci_dashboard"), \
+                mock.patch.object(gen, "build_site_usage"), \
+                mock.patch.object(gen, "published_public_repos", return_value=[("old", old), ("new", new)]):
+            gen.build_index()
+            gen.build_apps_index()
+        for html_ in written.values():
+            self.assertIn("<table", html_)
+            self.assertNotIn('<ul class="repos"', html_)
+            self.assertLess(html_.index('>new</a>'), html_.index('>old</a>'))
+        self.assertIn("2026-06-01", written[os.path.join(apps, "index.html")])
+        self.assertIn("2026-11-03", written[os.path.join(apps, "index.html")])  # deploy date shown, not used for order
+        self.assertNotIn('class="date"', "".join(written.values()))  # .log td.date is hidden on phones; headers would misalign
+
+    def test_apps_index_leaves_last_commit_blank_when_repo_missing_and_tolerates_null_fields(self):
+        root, apps = tempfile.mkdtemp(), tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, root, True)
+        self.addCleanup(shutil.rmtree, apps, True)
+        os.makedirs(os.path.join(apps, "gone"))
+        with open(os.path.join(apps, "gone", ".fuzzyworld.json"), "w") as f:
+            json.dump({"title": "gone", "repo": "gone", "deployed": "2026-11-03T00:00:00Z", "description": None}, f)
+        written = {}
+        with mock.patch.object(gen, "write", lambda p, c: written.update({p: c})), mock.patch.object(gen, "GIT_ROOT", root), \
+                mock.patch.object(gen, "APPS", apps):
+            gen.build_apps_index()
+        row = re.search(r"<tr><td class=\"shot\">.*?</tr>", written[os.path.join(apps, "index.html")]).group(0)
+        cells = re.findall(r'<td class="num">(.*?)</td>', row)
+        self.assertEqual(cells, ["", "2026-11-03"])  # last commit blank, deployed shown
+
 
 if __name__ == "__main__":
     unittest.main()