fuzzyworld-tools / commit
Code and apps indexes: tables, ordered by newest code commit (Q109)
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}/<repo>.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()