Skip to content

Commit 6b3855c

Browse files
committed
feat(web-frontend): Build dropdown fetch and sorts tags instead of GitHub releases
Fetching and parsing/sorting logic are stored in utils/version.ts
1 parent b376158 commit 6b3855c

File tree

7 files changed

+149
-65
lines changed

7 files changed

+149
-65
lines changed

web-frontend/src/app/layout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import type { Metadata } from "next";
2222
import Script from "next/script";
2323
import Navigation from "@/components/Navigation";
2424
import VersionWarningBanner from "@/components/VersionWarningBanner";
25-
import { CURRENT_APP_VERSION } from "@/constants/version";
25+
import { CURRENT_APP_VERSION } from "@/utils/version";
2626
import {
2727
GITHUB_REPO_URL,
2828
GITHUB_RELEASES_URL,

web-frontend/src/app/page.tsx

Lines changed: 7 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,8 @@
2323
import { useState, useEffect, useMemo, useRef } from 'react';
2424
import { FiChevronDown, FiCheck } from 'react-icons/fi';
2525
import { useSchedulingData } from '@/hooks/useSchedulingData';
26-
import { STATIC_BUILD_URLS, GITHUB_BRANCHES_API_URL } from '@/constants/urls';
27-
28-
type BuildEntry = { label: string; url: string };
26+
import { STATIC_BUILD_URLS } from '@/constants/urls';
27+
import { fetchReleaseBranches, BuildEntry } from '@/utils/version';
2928

3029
export default function Home() {
3130
const { createNewState } = useSchedulingData();
@@ -40,24 +39,11 @@ export default function Home() {
4039
}, []);
4140

4241
useEffect(() => {
43-
const fetchReleaseBranches = async () => {
44-
try {
45-
const response = await fetch(GITHUB_BRANCHES_API_URL);
46-
if (!response.ok) return;
47-
const branches: { name: string }[] = await response.json();
48-
const releases = branches
49-
.map((b) => b.name.match(/^release\/(.+)$/))
50-
.filter((m): m is RegExpMatchArray => m !== null)
51-
.map((m) => ({
52-
label: `v${m[1]}`,
53-
url: `https://release-${m[1].replace(/\./g, '-')}.nursescheduling.org`,
54-
}));
55-
setReleaseBranches(releases);
56-
} catch {
57-
// Silently fail - releases just won't show
58-
}
42+
const loadReleaseBranches = async () => {
43+
const releases = await fetchReleaseBranches();
44+
setReleaseBranches(releases);
5945
};
60-
fetchReleaseBranches();
46+
loadReleaseBranches();
6147
}, []);
6248

6349
useEffect(() => {
@@ -154,7 +140,7 @@ export default function Home() {
154140
</button>
155141

156142
{isDropdownOpen && (
157-
<div className="absolute bottom-full mb-2 right-0 w-64 bg-white rounded-lg shadow-lg border border-gray-200">
143+
<div className="absolute bottom-full mb-2 right-0 w-64 max-h-64 overflow-y-auto bg-white rounded-lg shadow-lg border border-gray-200">
158144
{buildUrls.map((build) => (
159145
<button
160146
key={build.label}

web-frontend/src/app/save-and-load/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import yaml from 'js-yaml';
2626
import { useSchedulingData } from '@/hooks/useSchedulingData';
2727
import ToggleButton from '@/components/ToggleButton';
2828
import UploadButton from '@/components/UploadButton';
29-
import { CURRENT_APP_VERSION } from '@/constants/version';
29+
import { CURRENT_APP_VERSION } from '@/utils/version';
3030

3131
// Type definitions for CustomDump class
3232
interface CustomDumpOptions {

web-frontend/src/components/VersionWarningBanner.tsx

Lines changed: 14 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@
2121

2222
import { useState, useEffect } from 'react';
2323
import { FiAlertTriangle, FiX } from 'react-icons/fi';
24-
import { CURRENT_APP_VERSION } from '@/constants/version';
25-
import { GITHUB_RELEASES_API_URL, WEBSITE_URL } from '@/constants/urls';
24+
import { CURRENT_APP_VERSION, fetchLatestTag, getMajorMinor } from '@/utils/version';
25+
import { WEBSITE_URL } from '@/constants/urls';
2626

2727
type VersionStatus = 'match' | 'older' | 'dev' | 'error' | null;
2828

@@ -32,29 +32,22 @@ export default function VersionWarningBanner() {
3232
const [fetchFailed, setFetchFailed] = useState(false);
3333

3434
useEffect(() => {
35-
const fetchLatestRelease = async () => {
36-
try {
37-
const response = await fetch(GITHUB_RELEASES_API_URL);
38-
if (!response.ok) {
39-
console.warn('Failed to fetch latest release:', response.status);
40-
setFetchFailed(true);
41-
return;
42-
}
43-
const data = await response.json();
44-
setLatestVersion(data.tag_name);
45-
} catch (err) {
46-
console.warn('Failed to fetch latest release:', err);
35+
const loadLatestTag = async () => {
36+
const tag = await fetchLatestTag();
37+
if (tag) {
38+
setLatestVersion(tag);
39+
} else {
4740
setFetchFailed(true);
4841
}
4942
};
5043

51-
fetchLatestRelease();
44+
loadLatestTag();
5245
}, []);
5346

5447
// Determine version status:
55-
// - 'error' -> failed to fetch latest release
48+
// - 'error' -> failed to fetch latest tag
5649
// - 'match' -> exact match
57-
// - 'dev' -> prefix match (e.g., "v1.0.0-5-gabcdef" starts with "v1.0.0")
50+
// - 'dev' -> same major.minor (e.g., "v1.0.0-5-gabcdef" matches "v1.0.1" on major.minor "v1.0")
5851
// - 'older' -> any other mismatch
5952
// - null -> still loading or unknown version
6053
const getVersionStatus = (): VersionStatus => {
@@ -75,8 +68,10 @@ export default function VersionWarningBanner() {
7568
return 'match';
7669
}
7770

78-
// Dev build: current version starts with latest version (prefix match)
79-
if (CURRENT_APP_VERSION.startsWith(latestVersion)) {
71+
// Dev build: current version has same major.minor as latest version
72+
const currentMajorMinor = getMajorMinor(CURRENT_APP_VERSION);
73+
const latestMajorMinor = getMajorMinor(latestVersion);
74+
if (currentMajorMinor && latestMajorMinor && currentMajorMinor === latestMajorMinor) {
8075
return 'dev';
8176
}
8277

web-frontend/src/constants/urls.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ export const GITHUB_LICENSE_URL = 'https://github.com/j3soon/nurse-scheduling/bl
2626
export const GITHUB_CODE_FREQUENCY_URL = 'https://github.com/j3soon/nurse-scheduling/graphs/code-frequency';
2727
export const GITHUB_ACKNOWLEDGMENTS_URL = 'https://github.com/j3soon/nurse-scheduling#acknowledgments';
2828
export const GITHUB_AUTHOR_URL = 'https://github.com/j3soon';
29-
// GitHub Releases API URL for fetching latest release
30-
export const GITHUB_RELEASES_API_URL = 'https://api.github.com/repos/j3soon/nurse-scheduling/releases/latest';
29+
// GitHub Tags API URL for fetching latest tag
30+
export const GITHUB_TAGS_API_URL = 'https://api.github.com/repos/j3soon/nurse-scheduling/tags';
3131
// GitHub Branches API URL for fetching release branches
3232
export const GITHUB_BRANCHES_API_URL = 'https://api.github.com/repos/j3soon/nurse-scheduling/branches';
3333

web-frontend/src/constants/version.ts

Lines changed: 0 additions & 21 deletions
This file was deleted.

web-frontend/src/utils/version.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/*
2+
* This file is part of Nurse Scheduling Project, see <https://github.com/j3soon/nurse-scheduling>.
3+
*
4+
* Copyright (C) 2023-2026 Johnson Sun
5+
*
6+
* This program is free software: you can redistribute it and/or modify
7+
* it under the terms of the GNU Affero General Public License as
8+
* published by the Free Software Foundation, either version 3 of the
9+
* License, or (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
* GNU Affero General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Affero General Public License
17+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
18+
*/
19+
20+
import { GITHUB_TAGS_API_URL, GITHUB_BRANCHES_API_URL } from '@/constants/urls';
21+
22+
// Current application version from environment variable.
23+
export const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || 'unknown';
24+
25+
// Type for release branch entries
26+
export type BuildEntry = { label: string; url: string };
27+
28+
/**
29+
* Parse a version string into its components for comparison.
30+
* Supports formats like "v1.0.0", "1.0.0", "v1.0", "1.0"
31+
* Returns null if the version cannot be parsed.
32+
*/
33+
export function parseVersion(version: string): { major: number; minor: number; patch: number } | null {
34+
const match = version.match(/^v?(\d+)\.(\d+)(?:\.(\d+))?/);
35+
if (!match) return null;
36+
return {
37+
major: parseInt(match[1], 10),
38+
minor: parseInt(match[2], 10),
39+
patch: match[3] !== undefined ? parseInt(match[3], 10) : 0,
40+
};
41+
}
42+
43+
/**
44+
* Compare two version strings for sorting (descending order - newest first).
45+
* Returns negative if a > b, positive if a < b, 0 if equal.
46+
*/
47+
export function compareVersionsDescending(a: string, b: string): number {
48+
const versionA = parseVersion(a);
49+
const versionB = parseVersion(b);
50+
51+
// Non-parseable versions go to the end
52+
if (!versionA && !versionB) return 0;
53+
if (!versionA) return 1;
54+
if (!versionB) return -1;
55+
56+
// Compare major, minor, patch (descending)
57+
if (versionA.major !== versionB.major) return versionB.major - versionA.major;
58+
if (versionA.minor !== versionB.minor) return versionB.minor - versionA.minor;
59+
return versionB.patch - versionA.patch;
60+
}
61+
62+
/**
63+
* Extract major.minor from a version string (e.g., "v1.0" from "v1.0.0" or "v1.0.0-5-gabcdef")
64+
*/
65+
export function getMajorMinor(version: string): string | null {
66+
const match = version.match(/^(v?\d+\.\d+)/);
67+
return match ? match[1] : null;
68+
}
69+
70+
/**
71+
* Fetch the latest tag from GitHub, sorted by semver (newest first).
72+
* Returns the latest tag name or null if fetch fails.
73+
*/
74+
export async function fetchLatestTag(): Promise<string | null> {
75+
try {
76+
const response = await fetch(GITHUB_TAGS_API_URL);
77+
if (!response.ok) {
78+
console.warn('Failed to fetch latest tag:', response.status);
79+
return null;
80+
}
81+
const tags: { name: string }[] = await response.json();
82+
if (tags.length === 0) {
83+
return null;
84+
}
85+
// Sort tags by semver (descending) and return the latest
86+
const sortedTags = tags
87+
.map((t) => t.name)
88+
.sort(compareVersionsDescending);
89+
return sortedTags[0] || null;
90+
} catch (err) {
91+
console.warn('Failed to fetch latest tag:', err);
92+
return null;
93+
}
94+
}
95+
96+
/**
97+
* Fetch release branches from GitHub and return them as BuildEntry objects,
98+
* sorted by semver (newest first).
99+
*/
100+
export async function fetchReleaseBranches(): Promise<BuildEntry[]> {
101+
try {
102+
const response = await fetch(GITHUB_BRANCHES_API_URL);
103+
if (!response.ok) {
104+
return [];
105+
}
106+
const branches: { name: string }[] = await response.json();
107+
const releases = branches
108+
.map((b) => b.name.match(/^release\/(.+)$/))
109+
.filter((m): m is RegExpMatchArray => m !== null)
110+
.map((m) => ({
111+
version: m[1],
112+
label: `v${m[1]}`,
113+
url: `https://release-${m[1].replace(/\./g, '-')}.nursescheduling.org`,
114+
}));
115+
116+
// Sort by version (descending - newest first)
117+
releases.sort((a, b) => compareVersionsDescending(a.version, b.version));
118+
119+
return releases.map(({ label, url }) => ({ label, url }));
120+
} catch {
121+
// Silently fail - releases just won't show
122+
return [];
123+
}
124+
}

0 commit comments

Comments
 (0)