diff --git a/.codespellrc b/.codespellrc index 7f9b3fa8..39d8ab1d 100644 --- a/.codespellrc +++ b/.codespellrc @@ -11,6 +11,7 @@ # categor - TypeScript template literal in website/src/scripts/pages/skills.ts:70 (categor${...length > 1 ? "ies" : "y"}) # aline - proper name (Aline Ávila, contributor) # ative - part of "Declarative Agents" in TypeSpec M365 Copilot documentation (collections/typespec-m365-copilot.collection.md) -ignore-words-list = numer,wit,aks,edn,ser,ois,gir,rouge,categor,aline,ative,afterall,deques +# dateA, dateB - variable names used in sorting comparison functions +ignore-words-list = numer,wit,aks,edn,ser,ois,gir,rouge,categor,aline,ative,afterall,deques,dateA,dateB # Skip certain files and directories skip = .git,node_modules,package-lock.json,*.lock,website/build,website/.docusaurus diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index e7f7e81d..aca15150 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -40,6 +40,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 # Full history needed for git-based last updated dates - name: Setup Node.js uses: actions/setup-node@v4 diff --git a/eng/generate-website-data.mjs b/eng/generate-website-data.mjs index 4a9b895c..48bed4b4 100644 --- a/eng/generate-website-data.mjs +++ b/eng/generate-website-data.mjs @@ -24,6 +24,7 @@ import { parseSkillMetadata, parseYamlFile, } from "./yaml-parser.mjs"; +import { getGitFileDates } from "./utils/git-dates.mjs"; const __filename = fileURLToPath(import.meta.url); @@ -64,7 +65,7 @@ function extractTitle(filePath, frontmatter) { /** * Generate agents metadata */ -function generateAgentsData() { +function generateAgentsData(gitDates) { const agents = []; const files = fs .readdirSync(AGENTS_DIR) @@ -105,6 +106,7 @@ function generateAgentsData() { : [], path: relativePath, filename: file, + lastUpdated: gitDates.get(relativePath) || null, }); } @@ -123,7 +125,7 @@ function generateAgentsData() { /** * Generate prompts metadata */ -function generatePromptsData() { +function generatePromptsData(gitDates) { const prompts = []; const files = fs .readdirSync(PROMPTS_DIR) @@ -151,6 +153,7 @@ function generatePromptsData() { tools: tools, path: relativePath, filename: file, + lastUpdated: gitDates.get(relativePath) || null, }); } @@ -206,7 +209,7 @@ function extractExtensionFromPattern(pattern) { /** * Generate instructions metadata */ -function generateInstructionsData() { +function generateInstructionsData(gitDates) { const instructions = []; const files = fs .readdirSync(INSTRUCTIONS_DIR) @@ -253,6 +256,7 @@ function generateInstructionsData() { extensions: [...new Set(extensions)], path: relativePath, filename: file, + lastUpdated: gitDates.get(relativePath) || null, }); } @@ -316,7 +320,7 @@ function categorizeSkill(name, description) { /** * Generate skills metadata */ -function generateSkillsData() { +function generateSkillsData(gitDates) { const skills = []; if (!fs.existsSync(SKILLS_DIR)) { @@ -343,6 +347,9 @@ function generateSkillsData() { // Get all files in the skill folder recursively const files = getSkillFiles(skillPath, relativePath); + // Get last updated from SKILL.md file + const skillFilePath = `${relativePath}/SKILL.md`; + skills.push({ id: folder, name: metadata.name, @@ -356,8 +363,9 @@ function generateSkillsData() { assetCount: metadata.assets.length, category: category, path: relativePath, - skillFile: `${relativePath}/SKILL.md`, + skillFile: skillFilePath, files: files, + lastUpdated: gitDates.get(skillFilePath) || null, }); } } @@ -406,7 +414,7 @@ function getSkillFiles(skillPath, relativePath) { /** * Generate collections metadata */ -function generateCollectionsData() { +function generateCollectionsData(gitDates) { const collections = []; if (!fs.existsSync(COLLECTIONS_DIR)) { @@ -447,6 +455,7 @@ function generateCollectionsData() { })), path: relativePath, filename: file, + lastUpdated: gitDates.get(relativePath) || null, }); } } @@ -542,6 +551,7 @@ function generateSearchIndex( title: agent.title, description: agent.description, path: agent.path, + lastUpdated: agent.lastUpdated, searchText: `${agent.title} ${agent.description} ${agent.tools.join( " " )}`.toLowerCase(), @@ -555,6 +565,7 @@ function generateSearchIndex( title: prompt.title, description: prompt.description, path: prompt.path, + lastUpdated: prompt.lastUpdated, searchText: `${prompt.title} ${prompt.description}`.toLowerCase(), }); } @@ -566,6 +577,7 @@ function generateSearchIndex( title: instruction.title, description: instruction.description, path: instruction.path, + lastUpdated: instruction.lastUpdated, searchText: `${instruction.title} ${instruction.description} ${ instruction.applyTo || "" }`.toLowerCase(), @@ -579,6 +591,7 @@ function generateSearchIndex( title: skill.title, description: skill.description, path: skill.skillFile, + lastUpdated: skill.lastUpdated, searchText: `${skill.title} ${skill.description}`.toLowerCase(), }); } @@ -591,6 +604,7 @@ function generateSearchIndex( description: collection.description, path: collection.path, tags: collection.tags, + lastUpdated: collection.lastUpdated, searchText: `${collection.name} ${ collection.description } ${collection.tags.join(" ")}`.toLowerCase(), @@ -703,32 +717,40 @@ async function main() { ensureDataDir(); + // Load git dates for all resource files (single efficient git command) + console.log("Loading git history for last updated dates..."); + const gitDates = getGitFileDates( + ["agents/", "prompts/", "instructions/", "skills/", "collections/"], + ROOT_FOLDER + ); + console.log(`✓ Loaded dates for ${gitDates.size} files\n`); + // Generate all data - const agentsData = generateAgentsData(); + const agentsData = generateAgentsData(gitDates); const agents = agentsData.items; console.log( `✓ Generated ${agents.length} agents (${agentsData.filters.models.length} models, ${agentsData.filters.tools.length} tools)` ); - const promptsData = generatePromptsData(); + const promptsData = generatePromptsData(gitDates); const prompts = promptsData.items; console.log( `✓ Generated ${prompts.length} prompts (${promptsData.filters.tools.length} tools)` ); - const instructionsData = generateInstructionsData(); + const instructionsData = generateInstructionsData(gitDates); const instructions = instructionsData.items; console.log( `✓ Generated ${instructions.length} instructions (${instructionsData.filters.extensions.length} extensions)` ); - const skillsData = generateSkillsData(); + const skillsData = generateSkillsData(gitDates); const skills = skillsData.items; console.log( `✓ Generated ${skills.length} skills (${skillsData.filters.categories.length} categories)` ); - const collectionsData = generateCollectionsData(); + const collectionsData = generateCollectionsData(gitDates); const collections = collectionsData.items; console.log( `✓ Generated ${collections.length} collections (${collectionsData.filters.tags.length} tags)` diff --git a/eng/utils/git-dates.mjs b/eng/utils/git-dates.mjs new file mode 100644 index 00000000..2f359699 --- /dev/null +++ b/eng/utils/git-dates.mjs @@ -0,0 +1,103 @@ +#!/usr/bin/env node + +/** + * Utility to extract last modification dates from git history. + * Uses a single git log command for efficiency. + */ + +import { execSync } from "child_process"; +import path from "path"; + +/** + * Get the last modification date for all tracked files in specified directories. + * Returns a Map of file path -> ISO date string. + * + * @param {string[]} directories - Array of directory paths to scan + * @param {string} rootDir - Root directory for relative paths + * @returns {Map} Map of relative file path to ISO date string + */ +export function getGitFileDates(directories, rootDir) { + const fileDates = new Map(); + + try { + // Get git log with file names for all specified directories + // Format: ISO date, then file names that were modified in that commit + const gitArgs = [ + "--no-pager", + "log", + "--format=%aI", // Author date in ISO 8601 format + "--name-only", + "--diff-filter=ACMR", // Added, Copied, Modified, Renamed + "--", + ...directories, + ]; + + const output = execSync(`git ${gitArgs.join(" ")}`, { + encoding: "utf8", + cwd: rootDir, + stdio: ["pipe", "pipe", "pipe"], + }); + + // Parse the output: alternating date lines and file name lines + // Format is: + // 2026-01-15T10:30:00+00:00 + // + // file1.md + // file2.md + // + // 2026-01-14T09:00:00+00:00 + // ... + + let currentDate = null; + const lines = output.split("\n"); + + for (const line of lines) { + const trimmed = line.trim(); + + if (!trimmed) { + continue; + } + + // Check if this is a date line (ISO 8601 format) + if (/^\d{4}-\d{2}-\d{2}T/.test(trimmed)) { + currentDate = trimmed; + } else if (currentDate && trimmed) { + // This is a file path - only set if we haven't seen this file yet + // (first occurrence is the most recent modification) + if (!fileDates.has(trimmed)) { + fileDates.set(trimmed, currentDate); + } + } + } + } catch (error) { + // Git command failed - might not be a git repo or no history + console.warn("Warning: Could not get git dates:", error.message); + } + + return fileDates; +} + +/** + * Get the last modification date for a single file. + * + * @param {string} filePath - Path to the file (relative to git root) + * @param {string} rootDir - Root directory + * @returns {string|null} ISO date string or null if not found + */ +export function getGitFileDate(filePath, rootDir) { + try { + const output = execSync( + `git --no-pager log -1 --format="%aI" -- "${filePath}"`, + { + encoding: "utf8", + cwd: rootDir, + stdio: ["pipe", "pipe", "pipe"], + } + ); + + const date = output.trim(); + return date || null; + } catch (error) { + return null; + } +} diff --git a/website/public/styles/global.css b/website/public/styles/global.css index 55d6244a..dd548fd1 100644 --- a/website/public/styles/global.css +++ b/website/public/styles/global.css @@ -1415,6 +1415,14 @@ a:hover { flex-shrink: 0; } +/* Last Updated */ +.last-updated { + font-size: 12px; + color: var(--color-text-muted); + cursor: default; + margin-left: auto; +} + /* Collection Items */ .collection-items { margin-top: 12px; diff --git a/website/src/pages/agents.astro b/website/src/pages/agents.astro index 172cebc3..49288b77 100644 --- a/website/src/pages/agents.astro +++ b/website/src/pages/agents.astro @@ -35,6 +35,13 @@ import Modal from '../components/Modal.astro'; Has Handoffs +
+ + +
diff --git a/website/src/pages/instructions.astro b/website/src/pages/instructions.astro index e9b1f0d1..63b4ac63 100644 --- a/website/src/pages/instructions.astro +++ b/website/src/pages/instructions.astro @@ -24,6 +24,13 @@ import Modal from '../components/Modal.astro'; +
+ + +
diff --git a/website/src/pages/prompts.astro b/website/src/pages/prompts.astro index 00778338..9b7b390a 100644 --- a/website/src/pages/prompts.astro +++ b/website/src/pages/prompts.astro @@ -24,6 +24,13 @@ import Modal from '../components/Modal.astro'; +
+ + +
diff --git a/website/src/pages/skills.astro b/website/src/pages/skills.astro index af40aad1..2866ae73 100644 --- a/website/src/pages/skills.astro +++ b/website/src/pages/skills.astro @@ -30,6 +30,13 @@ import Modal from '../components/Modal.astro'; Has Bundled Assets +
+ + +
diff --git a/website/src/scripts/pages/agents.ts b/website/src/scripts/pages/agents.ts index 5fd6f71b..28a822af 100644 --- a/website/src/scripts/pages/agents.ts +++ b/website/src/scripts/pages/agents.ts @@ -3,7 +3,7 @@ */ import { createChoices, getChoicesValues, type Choices } from '../choices'; import { FuzzySearch, SearchItem } from '../search'; -import { fetchData, debounce, escapeHtml, getGitHubUrl, getInstallDropdownHtml, setupDropdownCloseHandlers, getActionButtonsHtml, setupActionHandlers } from '../utils'; +import { fetchData, debounce, escapeHtml, getGitHubUrl, getInstallDropdownHtml, setupDropdownCloseHandlers, getActionButtonsHtml, setupActionHandlers, getLastUpdatedHtml } from '../utils'; import { setupModal, openFileModal } from '../modal'; interface Agent extends SearchItem { @@ -11,6 +11,7 @@ interface Agent extends SearchItem { model?: string; tools?: string[]; hasHandoffs?: boolean; + lastUpdated?: string | null; } interface AgentsData { @@ -21,11 +22,14 @@ interface AgentsData { }; } +type SortOption = 'title' | 'lastUpdated'; + const resourceType = 'agent'; let allItems: Agent[] = []; let search = new FuzzySearch(); let modelSelect: Choices; let toolSelect: Choices; +let currentSort: SortOption = 'title'; let currentFilters = { models: [] as string[], @@ -33,6 +37,19 @@ let currentFilters = { hasHandoffs: false, }; +function sortItems(items: Agent[]): Agent[] { + return [...items].sort((a, b) => { + if (currentSort === 'lastUpdated') { + // Sort by last updated (newest first), with null/undefined at end + const dateA = a.lastUpdated ? new Date(a.lastUpdated).getTime() : 0; + const dateB = b.lastUpdated ? new Date(b.lastUpdated).getTime() : 0; + return dateB - dateA; + } + // Default: sort by title + return a.title.localeCompare(b.title); + }); +} + function applyFiltersAndRender(): void { const searchInput = document.getElementById('search-input') as HTMLInputElement; const countEl = document.getElementById('results-count'); @@ -59,6 +76,9 @@ function applyFiltersAndRender(): void { results = results.filter(item => item.hasHandoffs); } + // Apply sorting + results = sortItems(results); + renderItems(results, query); const activeFilters: string[] = []; @@ -97,6 +117,7 @@ function renderItems(items: Agent[], query = ''): void { ${item.tools?.slice(0, 3).map(t => `${escapeHtml(t)}`).join('') || ''} ${item.tools && item.tools.length > 3 ? `+${item.tools.length - 3} more` : ''} ${item.hasHandoffs ? `handoffs` : ''} + ${getLastUpdatedHtml(item.lastUpdated)}
@@ -123,6 +144,7 @@ export async function initAgentsPage(): Promise { const searchInput = document.getElementById('search-input') as HTMLInputElement; const handoffsCheckbox = document.getElementById('filter-handoffs') as HTMLInputElement; const clearFiltersBtn = document.getElementById('clear-filters'); + const sortSelect = document.getElementById('sort-select') as HTMLSelectElement; const data = await fetchData('agents.json'); if (!data || !data.items) { @@ -149,6 +171,12 @@ export async function initAgentsPage(): Promise { applyFiltersAndRender(); }); + // Initialize sort select + sortSelect?.addEventListener('change', () => { + currentSort = sortSelect.value as SortOption; + applyFiltersAndRender(); + }); + applyFiltersAndRender(); searchInput?.addEventListener('input', debounce(() => applyFiltersAndRender(), 200)); @@ -160,10 +188,12 @@ export async function initAgentsPage(): Promise { clearFiltersBtn?.addEventListener('click', () => { currentFilters = { models: [], tools: [], hasHandoffs: false }; + currentSort = 'title'; modelSelect.removeActiveItems(); toolSelect.removeActiveItems(); if (handoffsCheckbox) handoffsCheckbox.checked = false; if (searchInput) searchInput.value = ''; + if (sortSelect) sortSelect.value = 'title'; applyFiltersAndRender(); }); diff --git a/website/src/scripts/pages/instructions.ts b/website/src/scripts/pages/instructions.ts index 500e1757..0501ec42 100644 --- a/website/src/scripts/pages/instructions.ts +++ b/website/src/scripts/pages/instructions.ts @@ -3,13 +3,14 @@ */ import { createChoices, getChoicesValues, type Choices } from '../choices'; import { FuzzySearch, SearchItem } from '../search'; -import { fetchData, debounce, escapeHtml, getGitHubUrl, getInstallDropdownHtml, setupDropdownCloseHandlers, getActionButtonsHtml, setupActionHandlers } from '../utils'; +import { fetchData, debounce, escapeHtml, getGitHubUrl, getInstallDropdownHtml, setupDropdownCloseHandlers, getActionButtonsHtml, setupActionHandlers, getLastUpdatedHtml } from '../utils'; import { setupModal, openFileModal } from '../modal'; interface Instruction extends SearchItem { path: string; applyTo?: string; extensions?: string[]; + lastUpdated?: string | null; } interface InstructionsData { @@ -19,11 +20,25 @@ interface InstructionsData { }; } +type SortOption = 'title' | 'lastUpdated'; + const resourceType = 'instruction'; let allItems: Instruction[] = []; let search = new FuzzySearch(); let extensionSelect: Choices; let currentFilters = { extensions: [] as string[] }; +let currentSort: SortOption = 'title'; + +function sortItems(items: Instruction[]): Instruction[] { + return [...items].sort((a, b) => { + if (currentSort === 'lastUpdated') { + const dateA = a.lastUpdated ? new Date(a.lastUpdated).getTime() : 0; + const dateB = b.lastUpdated ? new Date(b.lastUpdated).getTime() : 0; + return dateB - dateA; + } + return a.title.localeCompare(b.title); + }); +} function applyFiltersAndRender(): void { const searchInput = document.getElementById('search-input') as HTMLInputElement; @@ -41,6 +56,8 @@ function applyFiltersAndRender(): void { }); } + results = sortItems(results); + renderItems(results, query); let countText = `${results.length} of ${allItems.length} instructions`; if (currentFilters.extensions.length > 0) { @@ -67,6 +84,7 @@ function renderItems(items: Instruction[], query = ''): void { ${item.applyTo ? `applies to: ${escapeHtml(item.applyTo)}` : ''} ${item.extensions?.slice(0, 4).map(e => `${escapeHtml(e)}`).join('') || ''} ${item.extensions && item.extensions.length > 4 ? `+${item.extensions.length - 4} more` : ''} + ${getLastUpdatedHtml(item.lastUpdated)}
@@ -92,6 +110,7 @@ export async function initInstructionsPage(): Promise { const list = document.getElementById('resource-list'); const searchInput = document.getElementById('search-input') as HTMLInputElement; const clearFiltersBtn = document.getElementById('clear-filters'); + const sortSelect = document.getElementById('sort-select') as HTMLSelectElement; const data = await fetchData('instructions.json'); if (!data || !data.items) { @@ -109,13 +128,20 @@ export async function initInstructionsPage(): Promise { applyFiltersAndRender(); }); + sortSelect?.addEventListener('change', () => { + currentSort = sortSelect.value as SortOption; + applyFiltersAndRender(); + }); + applyFiltersAndRender(); searchInput?.addEventListener('input', debounce(() => applyFiltersAndRender(), 200)); clearFiltersBtn?.addEventListener('click', () => { currentFilters = { extensions: [] }; + currentSort = 'title'; extensionSelect.removeActiveItems(); if (searchInput) searchInput.value = ''; + if (sortSelect) sortSelect.value = 'title'; applyFiltersAndRender(); }); diff --git a/website/src/scripts/pages/prompts.ts b/website/src/scripts/pages/prompts.ts index e660d727..02799ad6 100644 --- a/website/src/scripts/pages/prompts.ts +++ b/website/src/scripts/pages/prompts.ts @@ -3,12 +3,13 @@ */ import { createChoices, getChoicesValues, type Choices } from '../choices'; import { FuzzySearch, SearchItem } from '../search'; -import { fetchData, debounce, escapeHtml, getGitHubUrl, getInstallDropdownHtml, setupDropdownCloseHandlers, getActionButtonsHtml, setupActionHandlers } from '../utils'; +import { fetchData, debounce, escapeHtml, getGitHubUrl, getInstallDropdownHtml, setupDropdownCloseHandlers, getActionButtonsHtml, setupActionHandlers, getLastUpdatedHtml } from '../utils'; import { setupModal, openFileModal } from '../modal'; interface Prompt extends SearchItem { path: string; tools?: string[]; + lastUpdated?: string | null; } interface PromptsData { @@ -18,11 +19,25 @@ interface PromptsData { }; } +type SortOption = 'title' | 'lastUpdated'; + const resourceType = 'prompt'; let allItems: Prompt[] = []; let search = new FuzzySearch(); let toolSelect: Choices; let currentFilters = { tools: [] as string[] }; +let currentSort: SortOption = 'title'; + +function sortItems(items: Prompt[]): Prompt[] { + return [...items].sort((a, b) => { + if (currentSort === 'lastUpdated') { + const dateA = a.lastUpdated ? new Date(a.lastUpdated).getTime() : 0; + const dateB = b.lastUpdated ? new Date(b.lastUpdated).getTime() : 0; + return dateB - dateA; + } + return a.title.localeCompare(b.title); + }); +} function applyFiltersAndRender(): void { const searchInput = document.getElementById('search-input') as HTMLInputElement; @@ -37,6 +52,8 @@ function applyFiltersAndRender(): void { ); } + results = sortItems(results); + renderItems(results, query); let countText = `${results.length} of ${allItems.length} prompts`; if (currentFilters.tools.length > 0) { @@ -62,6 +79,7 @@ function renderItems(items: Prompt[], query = ''): void {
${item.tools?.slice(0, 4).map(t => `${escapeHtml(t)}`).join('') || ''} ${item.tools && item.tools.length > 4 ? `+${item.tools.length - 4} more` : ''} + ${getLastUpdatedHtml(item.lastUpdated)}
@@ -87,6 +105,7 @@ export async function initPromptsPage(): Promise { const list = document.getElementById('resource-list'); const searchInput = document.getElementById('search-input') as HTMLInputElement; const clearFiltersBtn = document.getElementById('clear-filters'); + const sortSelect = document.getElementById('sort-select') as HTMLSelectElement; const data = await fetchData('prompts.json'); if (!data || !data.items) { @@ -104,13 +123,20 @@ export async function initPromptsPage(): Promise { applyFiltersAndRender(); }); + sortSelect?.addEventListener('change', () => { + currentSort = sortSelect.value as SortOption; + applyFiltersAndRender(); + }); + applyFiltersAndRender(); searchInput?.addEventListener('input', debounce(() => applyFiltersAndRender(), 200)); clearFiltersBtn?.addEventListener('click', () => { currentFilters = { tools: [] }; + currentSort = 'title'; toolSelect.removeActiveItems(); if (searchInput) searchInput.value = ''; + if (sortSelect) sortSelect.value = 'title'; applyFiltersAndRender(); }); diff --git a/website/src/scripts/pages/skills.ts b/website/src/scripts/pages/skills.ts index 9689833f..e079ff4e 100644 --- a/website/src/scripts/pages/skills.ts +++ b/website/src/scripts/pages/skills.ts @@ -10,6 +10,7 @@ import { getGitHubUrl, getRawGitHubUrl, showToast, + getLastUpdatedHtml, } from "../utils"; import { setupModal, openFileModal } from "../modal"; import JSZip from "../jszip"; @@ -27,6 +28,7 @@ interface Skill extends SearchItem { hasAssets: boolean; assetCount: number; files: SkillFile[]; + lastUpdated?: string | null; } interface SkillsData { @@ -36,6 +38,8 @@ interface SkillsData { }; } +type SortOption = 'title' | 'lastUpdated'; + const resourceType = "skill"; let allItems: Skill[] = []; let search = new FuzzySearch(); @@ -44,6 +48,18 @@ let currentFilters = { categories: [] as string[], hasAssets: false, }; +let currentSort: SortOption = 'title'; + +function sortItems(items: Skill[]): Skill[] { + return [...items].sort((a, b) => { + if (currentSort === 'lastUpdated') { + const dateA = a.lastUpdated ? new Date(a.lastUpdated).getTime() : 0; + const dateB = b.lastUpdated ? new Date(b.lastUpdated).getTime() : 0; + return dateB - dateA; + } + return a.title.localeCompare(b.title); + }); +} function applyFiltersAndRender(): void { const searchInput = document.getElementById( @@ -63,6 +79,8 @@ function applyFiltersAndRender(): void { results = results.filter((item) => item.hasAssets); } + results = sortItems(results); + renderItems(results, query); const activeFilters: string[] = []; if (currentFilters.categories.length > 0) @@ -116,6 +134,7 @@ function renderItems(items: Skill[], query = ""): void { ${item.files.length} file${ item.files.length === 1 ? "" : "s" } + ${getLastUpdatedHtml(item.lastUpdated)}
@@ -236,6 +255,7 @@ export async function initSkillsPage(): Promise { "filter-has-assets" ) as HTMLInputElement; const clearFiltersBtn = document.getElementById("clear-filters"); + const sortSelect = document.getElementById("sort-select") as HTMLSelectElement; const data = await fetchData("skills.json"); if (!data || !data.items) { @@ -262,6 +282,11 @@ export async function initSkillsPage(): Promise { applyFiltersAndRender(); }); + sortSelect?.addEventListener("change", () => { + currentSort = sortSelect.value as SortOption; + applyFiltersAndRender(); + }); + applyFiltersAndRender(); searchInput?.addEventListener( "input", @@ -275,9 +300,11 @@ export async function initSkillsPage(): Promise { clearFiltersBtn?.addEventListener("click", () => { currentFilters = { categories: [], hasAssets: false }; + currentSort = 'title'; categorySelect.removeActiveItems(); if (hasAssetsCheckbox) hasAssetsCheckbox.checked = false; if (searchInput) searchInput.value = ""; + if (sortSelect) sortSelect.value = "title"; applyFiltersAndRender(); }); diff --git a/website/src/scripts/utils.ts b/website/src/scripts/utils.ts index 479c7082..4e381020 100644 --- a/website/src/scripts/utils.ts +++ b/website/src/scripts/utils.ts @@ -429,3 +429,75 @@ export function setupActionHandlers(): void { let dropdownHandlersReady = false; let actionHandlersReady = false; + +/** + * Format a date as relative time (e.g., "3 days ago") + * @param isoDate - ISO 8601 date string + * @returns Relative time string + */ +export function formatRelativeTime(isoDate: string | null | undefined): string { + if (!isoDate) return "Unknown"; + + const date = new Date(isoDate); + if (isNaN(date.getTime())) return "Unknown"; + + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffSeconds = Math.floor(diffMs / 1000); + const diffMinutes = Math.floor(diffSeconds / 60); + const diffHours = Math.floor(diffMinutes / 60); + const diffDays = Math.floor(diffHours / 24); + const diffWeeks = Math.floor(diffDays / 7); + const diffMonths = Math.floor(diffDays / 30); + const diffYears = Math.floor(diffDays / 365); + + if (diffDays === 0) { + if (diffHours === 0) { + if (diffMinutes === 0) return "just now"; + return diffMinutes === 1 ? "1 minute ago" : `${diffMinutes} minutes ago`; + } + return diffHours === 1 ? "1 hour ago" : `${diffHours} hours ago`; + } + if (diffDays === 1) return "yesterday"; + if (diffDays < 7) return `${diffDays} days ago`; + if (diffWeeks === 1) return "1 week ago"; + if (diffWeeks < 4) return `${diffWeeks} weeks ago`; + if (diffMonths === 1) return "1 month ago"; + if (diffMonths < 12) return `${diffMonths} months ago`; + if (diffYears === 1) return "1 year ago"; + return `${diffYears} years ago`; +} + +/** + * Format a date for display (e.g., "January 15, 2026") + * @param isoDate - ISO 8601 date string + * @returns Formatted date string + */ +export function formatFullDate(isoDate: string | null | undefined): string { + if (!isoDate) return "Unknown"; + + const date = new Date(isoDate); + if (isNaN(date.getTime())) return "Unknown"; + + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); +} + +/** + * Generate HTML for displaying last updated time with hover tooltip + * @param isoDate - ISO 8601 date string + * @returns HTML string with relative time and title attribute + */ +export function getLastUpdatedHtml(isoDate: string | null | undefined): string { + const relativeTime = formatRelativeTime(isoDate); + const fullDate = formatFullDate(isoDate); + + if (relativeTime === "Unknown") { + return `Updated: Unknown`; + } + + return `Updated ${relativeTime}`; +}