[READ-ONLY] Mirror of https://github.com/probablykasper/starchart. GitHub star history graph starchart.kasper.space
chart github github-api github-star graph history star star-history stargazers stars
0

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #4 from probablykasper/concurrency

authored by

Kasper and committed by
GitHub
(May 15, 2025, 12:28 AM +0200) b0c82390 1f0c6dbb

+331 -161
+4 -4
package-lock.json
··· 18 18 "eslint-plugin-svelte": "^3.0.3", 19 19 "html2canvas": "^1.4.1", 20 20 "isomorphic-fetch": "^3.0.0", 21 - "lightweight-charts": "^4.2.3", 21 + "lightweight-charts": "^5.0.6", 22 22 "modal-svelte": "^1.4.0", 23 23 "prettier": "^3.5.3", 24 24 "prettier-plugin-svelte": "^3.3.3", ··· 3632 3632 } 3633 3633 }, 3634 3634 "node_modules/lightweight-charts": { 3635 - "version": "4.2.3", 3636 - "resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-4.2.3.tgz", 3637 - "integrity": "sha512-5kS/2hY3wNYNzhnS8Gb+GAS07DX8GPF2YVDnd2NMC85gJVQ6RLU6YrXNgNJ6eg0AnWPwCnvaGtYmGky3HiLQEw==", 3635 + "version": "5.0.6", 3636 + "resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-5.0.6.tgz", 3637 + "integrity": "sha512-A7mkToJ3ZPecKkWWM5afN5PZUUf5BlQ+UgcYbWuSlpe1V18n61ahtbtToCSIL0VpSgE5sQPJ/k+/ieQ7/FJe+Q==", 3638 3638 "dev": true, 3639 3639 "license": "Apache-2.0", 3640 3640 "dependencies": {
+1 -1
package.json
··· 22 22 "eslint-plugin-svelte": "^3.0.3", 23 23 "html2canvas": "^1.4.1", 24 24 "isomorphic-fetch": "^3.0.0", 25 - "lightweight-charts": "^4.2.3", 25 + "lightweight-charts": "^5.0.6", 26 26 "modal-svelte": "^1.4.0", 27 27 "prettier": "^3.5.3", 28 28 "prettier-plugin-svelte": "^3.3.3",
+11 -21
src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { fly, slide } from 'svelte/transition' 3 - import { errors, fetch_stargazers_page } from './github' 3 + import { errors, RepoStars } from './github' 4 4 import { onMount, tick } from 'svelte' 5 5 import '../app.sass' 6 6 import Nav from './Nav.svelte' ··· 13 13 let [owner, repo] = ['', ''] 14 14 15 15 let chart: Chart | undefined 16 - 17 16 async function get_stargazers(owner: string, repo: string) { 18 17 if (!chart || !$chart) { 19 18 errors.push('Chart not initialized') ··· 24 23 return // already added 25 24 } 26 25 } 26 + let repo_stars = new RepoStars(owner, repo) 27 27 let count = 0 28 - let end_cursor: string | undefined 29 28 const line = chart.addLine({ 30 29 name: `${owner}/${repo}`, 31 30 color: get_next_color_index(), 32 31 data: [], 33 32 }) 34 - let total_count = 0 33 + let i = 0 35 34 do { 36 35 if (line.deleted) { 37 36 return // abort 38 37 } 39 - const { error, stargazers } = await fetch_stargazers_page(owner, repo, 'forward', end_cursor) 38 + const { error, stargazers } = await repo_stars.fetch_concurrent() 40 39 if (!stargazers) { 41 40 errors.push(error) 42 41 chart.deleteLine(line) 43 42 return 44 43 } 45 44 46 - if (stargazers.pageInfo.hasNextPage) { 47 - end_cursor = stargazers.pageInfo.endCursor 48 - } else { 49 - end_cursor = undefined 45 + chart.updateStargazers(line, repo_stars.data_points) 46 + if (i === 1) { 47 + // for some reason doesn't work on index 0 48 + chart.resetZoom() 50 49 } 51 - total_count = stargazers.totalCount 52 - 53 - const new_data = stargazers.starTimes.map((star_time) => { 54 - count++ 55 - return { 56 - t: Math.floor(new Date(star_time).getTime() / 1000) as UTCTimestamp, 57 - v: count, 58 - } 59 - }) 60 - chart.appendStargazers(line, new_data) 61 - } while (end_cursor) 50 + i++ 51 + } while (repo_stars.request_queue.length > 0) 62 52 chart.addFinal(line, { 63 53 t: Math.floor(new Date().getTime() / 1000) as UTCTimestamp, 64 - v: Math.max(total_count, count), 54 + v: Math.max(repo_stars.total_count, count), 65 55 }) 66 56 chart.save() 67 57 }
+110 -45
src/routes/Label.svelte
··· 1 1 <script lang="ts"> 2 2 import { cubicOut } from 'svelte/easing' 3 - import { fade, scale } from 'svelte/transition' 3 + import { fade, fly, scale } from 'svelte/transition' 4 4 import type { Line } from './chart' 5 5 import { hex_colors } from './color' 6 6 ··· 10 10 export let on_delete: () => void 11 11 </script> 12 12 13 - <span 14 - class="serie" 15 - class:screenshot-mode={screenshot_mode} 16 - style:--color-1={hex} 17 - style:--color-2={hex + '77'} 18 - in:scale={{ duration: 200, easing: cubicOut, start: 0.75, opacity: 0 }} 13 + <!-- svelte-ignore a11y_no_noninteractive_tabindex --> 14 + <!-- svelte-ignore a11y_no_static_element_interactions --> 15 + <div 16 + class="container" 19 17 class:hidden={line.hidden} 18 + style:--color-1={hex} 19 + style:--color-2="color-mix(in srgb, black, var(--color-1) 47%)" 20 20 tabindex="0" 21 21 on:keydown={(e) => { 22 22 if (e.key === ' ' || e.key === 'Enter') { ··· 28 28 e.preventDefault() 29 29 } 30 30 }} 31 - role="button" 31 + in:scale={{ duration: 200, easing: cubicOut, start: 0.75, opacity: 0 }} 32 32 > 33 - <button 34 - type="button" 35 - class="name" 36 - tabindex="-1" 37 - on:click={() => { 38 - line.hidden = !line.hidden 39 - line.instance.applyOptions({ visible: !line.hidden }) 40 - }} 41 - > 42 - {line.name} 43 - </button> 44 - <button type="button" class="x" class:loading={!line.final} on:click={on_delete} tabindex="-1"> 45 - <svg 46 - fill="currentColor" 47 - width="18" 48 - height="18" 49 - clip-rule="evenodd" 50 - fill-rule="evenodd" 51 - stroke-linejoin="round" 52 - stroke-miterlimit="2" 53 - viewBox="0 0 24 24" 54 - xmlns="http://www.w3.org/2000/svg" 55 - ><path 56 - d="m12 10.93 5.719-5.72c.146-.146.339-.219.531-.219.404 0 .75.324.75.749 0 .193-.073.385-.219.532l-5.72 5.719 5.719 5.719c.147.147.22.339.22.531 0 .427-.349.75-.75.75-.192 0-.385-.073-.531-.219l-5.719-5.719-5.719 5.719c-.146.146-.339.219-.531.219-.401 0-.75-.323-.75-.75 0-.192.073-.384.22-.531l5.719-5.719-5.72-5.719c-.146-.147-.219-.339-.219-.532 0-.425.346-.749.75-.749.192 0 .385.073.531.219z" 57 - /></svg 33 + {#if !line.final} 34 + <div class="counter-container"> 35 + <div class="counter-bg"> 36 + {line.data.length} 37 + </div> 38 + </div> 39 + <div class="counter-container"> 40 + <div class="counter"> 41 + <span class="counter-sizing-text">{line.data.length}</span> 42 + {#key line.data.length} 43 + <span 44 + class="counter-text" 45 + in:fly={{ duration: 300, y: 10 }} 46 + out:fly={{ duration: 300, y: -10 }}>{line.data.length}</span 47 + > 48 + {/key} 49 + </div> 50 + </div> 51 + {/if} 52 + <span class="serie" class:screenshot-mode={screenshot_mode}> 53 + <button 54 + type="button" 55 + class="name" 56 + tabindex="-1" 57 + on:click={() => { 58 + line.hidden = !line.hidden 59 + line.instance.applyOptions({ visible: !line.hidden }) 60 + }} 58 61 > 59 - {#if !line.final} 60 - <div class="spinner" transition:fade={{ duration: 150 }}> 61 - <div class="circle"></div> 62 - </div> 63 - {/if} 64 - </button> 65 - </span> 62 + {line.name} 63 + </button> 64 + <button type="button" class="x" class:loading={!line.final} on:click={on_delete} tabindex="-1"> 65 + <svg 66 + fill="currentColor" 67 + width="18" 68 + height="18" 69 + clip-rule="evenodd" 70 + fill-rule="evenodd" 71 + stroke-linejoin="round" 72 + stroke-miterlimit="2" 73 + viewBox="0 0 24 24" 74 + xmlns="http://www.w3.org/2000/svg" 75 + ><path 76 + d="m12 10.93 5.719-5.72c.146-.146.339-.219.531-.219.404 0 .75.324.75.749 0 .193-.073.385-.219.532l-5.72 5.719 5.719 5.719c.147.147.22.339.22.531 0 .427-.349.75-.75.75-.192 0-.385-.073-.531-.219l-5.719-5.719-5.719 5.719c-.146.146-.339.219-.531.219-.401 0-.75-.323-.75-.75 0-.192.073-.384.22-.531l5.719-5.719-5.72-5.719c-.146-.147-.219-.339-.219-.532 0-.425.346-.749.75-.749.192 0 .385.073.531.219z" 77 + /></svg 78 + > 79 + {#if !line.final} 80 + <div class="spinner" transition:fade={{ duration: 150 }}> 81 + <div class="circle"></div> 82 + </div> 83 + {/if} 84 + </button> 85 + </span> 86 + </div> 66 87 67 88 <style lang="sass"> 89 + .container 90 + position: relative 91 + outline: none 68 92 .serie 69 93 border-radius: 8px 70 94 border: 2px solid 71 95 display: inline-flex 72 96 align-items: center 73 - font-size: 0.9rem 97 + font-size: 0.9375rem 74 98 font-weight: 500 75 99 transition: 100ms ease-out 76 100 outline: 1px solid transparent 77 101 background-color: var(--color-2) 78 102 border-color: var(--color-1) 79 103 height: 24px 80 - &:focus-visible 81 - border-color: hsla(0, 0%, 100%, 1) 104 + position: relative 82 105 svg 83 106 display: block 107 + .container:focus-visible .serie 108 + border-color: hsla(0, 0%, 100%, 1) 109 + .counter-container 110 + position: absolute 111 + bottom: -0.9375rem 112 + font-size: 0.75rem 113 + width: 100% 114 + display: flex 115 + justify-content: center 116 + user-select: none 117 + overflow: hidden 118 + .counter-bg, .counter 119 + padding-bottom: 0.175rem 120 + padding-right: 0.5rem 121 + padding-left: 0.5rem 122 + .counter-bg 123 + border: 2px solid 124 + background-color: var(--color-2) 125 + border-color: var(--color-1) 126 + border-radius: 8px 127 + color: transparent 128 + .container:focus-visible .counter-bg 129 + border-color: hsla(0, 0%, 100%, 1) 130 + .counter 131 + z-index: 1 132 + background-color: var(--color-2) 133 + border-bottom-left-radius: 8px 134 + border-bottom-right-radius: 8px 135 + margin-bottom: 0.175rem 136 + padding-bottom: 0rem 137 + position: relative 138 + span 139 + pointer-events: none 140 + .counter-sizing-text 141 + color: transparent 142 + .counter-text 143 + position: absolute 144 + top: 0 145 + left: 0 146 + width: 100% 84 147 .hidden 85 148 opacity: 0.6 86 - text-decoration: line-through 87 - border-style: dashed 149 + button 150 + text-decoration: line-through 151 + .serie, .counter-bg 152 + border-style: dashed 88 153 button 89 154 background: none 90 155 border: none
+35 -47
src/routes/chart.ts
··· 1 1 import { 2 + AreaSeries, 2 3 createChart as create_chart_instance, 3 4 PriceScaleMode, 4 5 TickMarkType, ··· 174 175 set_default_formatting() 175 176 } 176 177 177 - const filler_line = chart.instance.addAreaSeries({ 178 + const filler_line = chart.instance.addSeries(AreaSeries, { 178 179 priceScaleId: '', 179 180 visible: false, 180 181 }) ··· 190 191 const store = { 191 192 subscribe, 192 193 193 - addLine(line_json: LineBase) { 194 - const series = chart.instance.addAreaSeries({ 194 + addLine(new_line: LineBase) { 195 + const series = chart.instance.addSeries(AreaSeries, { 195 196 priceLineVisible: false, 196 - topColor: top_colors[line_json.color], 197 - bottomColor: bottom_colors[line_json.color], 198 - lineColor: hex_colors[line_json.color], 197 + topColor: top_colors[new_line.color], 198 + bottomColor: bottom_colors[new_line.color], 199 + lineColor: hex_colors[new_line.color], 199 200 priceFormat: { 200 201 type: 'custom', 201 202 formatter: (price: number) => { ··· 205 206 }) 206 207 207 208 const line: Line = { 208 - ...line_json, 209 + ...new_line, 209 210 instance: series, 210 211 deleted: false, 211 212 } 212 - if (line_json.data.length >= 1) { 213 - const start = line_json.data[0] 214 - const end = line_json.final ?? line_json.data[line_json.data.length - 1] 215 - let chart_series = to_chart_series(line_json.data, start, end) 213 + if (new_line.data.length >= 1) { 214 + let chart_series = to_chart_series(new_line.data) 216 215 217 216 if (chart.align) { 218 217 chart_series = align_chart_series(chart_series) ··· 221 220 series.setData(chart_series) 222 221 } 223 222 chart.lines.push(line) 224 - update_filler(chart.lines) 225 - if (line_json.data.length >= 1) { 223 + if (line.data.length >= 1) { 224 + update_filler(chart.lines) 226 225 store.resetZoom() 227 226 } 228 227 set(chart) ··· 235 234 if (line.deleted || line.data.length === 0) { 236 235 continue 237 236 } 238 - const start = line.data[0] 239 - const end = line.data[line.data.length - 1] 240 - const chart_series = to_chart_series(line.data, start, end) 237 + const chart_series = to_chart_series(line.data) 241 238 const aligned_chart_series = align_chart_series(chart_series) 242 239 line.instance.setData(aligned_chart_series) 243 240 line.lastChartSeriesDate = aligned_chart_series[aligned_chart_series.length - 1].time ··· 254 251 if (line.deleted || line.data.length === 0) { 255 252 continue 256 253 } 257 - const start = line.data[0] 258 - const end = line.data[line.data.length - 1] 259 - const chart_series = to_chart_series(line.data, start, end) 254 + const chart_series = to_chart_series(line.data) 260 255 line.instance.setData(chart_series) 261 256 line.lastChartSeriesDate = chart_series[chart_series.length - 1].time 262 257 } ··· 282 277 set(chart) 283 278 }, 284 279 285 - _appendAlignedChartData(line: Line, data: DataPoint[]) { 286 - if (data.length === 0) { 280 + _appendAlignedChartData(line: Line) { 281 + if (line.data.length === 0) { 287 282 return 288 283 } 289 - const start = data[0] 290 - const end = data[data.length - 1] 291 - const chart_series = to_chart_series(data, start, end) 284 + const chart_series = to_chart_series(line.data) 292 285 const fresh = line.data.length === 0 293 286 if (fresh) { 294 - const aigned_chart_series = align_chart_series(chart_series) 295 - line.instance.setData(aigned_chart_series) 296 - line.lastChartSeriesDate = aigned_chart_series[aigned_chart_series.length - 1].time 287 + const aligned_chart_series = align_chart_series(chart_series) 288 + line.instance.setData(aligned_chart_series) 289 + line.lastChartSeriesDate = aligned_chart_series[aligned_chart_series.length - 1].time 297 290 } else { 298 291 const start_day = line.lastChartSeriesDate 299 292 if (!start_day) { ··· 303 296 const aligned_chart_series = align_chart_series(chart_series, start_date) 304 297 line.lastChartSeriesDate = aligned_chart_series[aligned_chart_series.length - 1].time 305 298 306 - for (const data_point of aligned_chart_series) { 307 - line.instance.update(data_point) 308 - } 299 + line.instance.setData(aligned_chart_series) 309 300 } 310 301 }, 311 302 312 - _appendChartData(line: Line, data: DataPoint[]) { 313 - if (data.length === 0) { 303 + _appendChartData(line: Line) { 304 + if (line.data.length === 0) { 314 305 return 315 306 } else if (chart.align) { 316 - return this._appendAlignedChartData(line, data) 307 + return this._appendAlignedChartData(line) 317 308 } 318 - const start = line.data[line.data.length - 1] ?? data[0] 319 - const end = line.final ?? data[data.length - 1] 320 - const chart_series = to_chart_series(data, start, end) 309 + const chart_series = to_chart_series(line.data) 321 310 line.lastChartSeriesDate = chart_series[chart_series.length - 1].time 322 311 const fresh = line.data.length === 0 323 - 324 312 if (fresh) { 325 313 line.instance.setData(chart_series) 326 314 } else { 327 - for (const data_point of chart_series) { 328 - line.instance.update(data_point) 329 - } 315 + line.instance.setData(chart_series) 330 316 } 331 317 }, 332 318 333 - appendStargazers(line: Line, data: DataPoint[]) { 319 + updateStargazers(line: Line, data: DataPoint[]) { 334 320 const fresh = line.data.length === 0 335 - store._appendChartData(line, data) 336 - line.data.push(...data) 321 + line.data = data 322 + store._appendChartData(line) 337 323 if (fresh) { 338 324 update_filler(chart.lines) 339 325 store.resetZoom() ··· 342 328 }, 343 329 344 330 addFinal(line: Line, data: DataPoint) { 345 - store._appendChartData(line, [data]) 346 - line.final = data 331 + line.data.push(data) 332 + store._appendChartData(line) 333 + line.final = line.data[line.data.length - 1] 347 334 update_filler(chart.lines) 348 335 set(chart) 349 336 }, ··· 437 424 } 438 425 value: number 439 426 } 440 - function to_chart_series(data: DataPoint[], start: DataPoint, final: DataPoint): ChartSeries[] { 427 + function to_chart_series(data: DataPoint[]): ChartSeries[] { 441 428 const data_points: { date: Date; value: number }[] = [] 429 + data.sort((a, b) => a.t - b.t) 442 430 443 - for (const data_point of [start, ...data, final]) { 431 + for (const data_point of data) { 444 432 const dt = new Date(data_point.t * 1000) 445 433 const date = new Date(dt.getFullYear(), dt.getMonth(), dt.getDate()) 446 434
+170 -43
src/routes/github.ts
··· 7 7 import { retry } from '@octokit/plugin-retry' 8 8 // @ts-expect-error import missing from bottleneck's package.json 9 9 import BottleneckLight from 'bottleneck/light.js' 10 + import type { UTCTimestamp } from 'lightweight-charts' 10 11 const Bottleneck = BottleneckLight as typeof import('bottleneck').default 11 12 12 13 const loaded_token = browser ? localStorage.getItem('starchart-token') : undefined ··· 72 73 }, 73 74 }) 74 75 75 - export async function fetch_stargazers_page( 76 - owner: string, 77 - repo: string, 78 - direction: 'forward' | 'back', 79 - cursor?: string, 80 - ) { 81 - const start_time = Date.now() 82 - const response_promise = octokit.graphql<{ 83 - repository: { 84 - stargazers: { 85 - totalCount: number 86 - pageInfo: { 87 - startCursor: string 88 - endCursor: string 89 - hasNextPage: boolean 90 - hasPreviousPage: boolean 91 - } 92 - edges: { starredAt: string }[] 76 + export class RepoStars { 77 + // Fetching user IDs would add 200ms to the response time, so we use timestamps instead 78 + forward_star_times = new Set<string>() 79 + backward_star_times = new Set<string>() 80 + forward_count = 0 81 + backward_count_initialised = false 82 + backward_count = -1 83 + /** null means no more pages */ 84 + next_cursor: { forward?: string; backward?: string } | null = {} 85 + total_count = 0 86 + request_queue: ReturnType<InstanceType<typeof RepoStars>['fetch']>[] = [] 87 + data_points: { t: UTCTimestamp; v: number }[] = [] 88 + constructor( 89 + public owner: string, 90 + public repo: string, 91 + ) { 92 + this.owner = owner 93 + this.repo = repo 94 + } 95 + 96 + async fetch_concurrent() { 97 + if (this.request_queue.length === 0) { 98 + this.request_queue.push(this.fetch('forward'), this.fetch('backward')) 99 + } 100 + const promise = this.request_queue[0] 101 + this.request_queue.shift() 102 + const result = await promise 103 + if (result?.stargazers && this.next_cursor) { 104 + this.request_queue.push(this.fetch(result.stargazers.direction)) 105 + } 106 + return result 107 + } 108 + 109 + async fetch(direction: 'forward' | 'backward') { 110 + if (this.next_cursor === null) { 111 + throw new Error('Unexpected RepoStars.fetch after completion') 112 + } 113 + // copy to prevent desync 114 + const cursor = { ...this.next_cursor } 115 + const start_time = Date.now() 116 + type Stargazers = { 117 + totalCount: number 118 + pageInfo: { 119 + startCursor: string 120 + endCursor: string 121 + hasNextPage: boolean 122 + hasPreviousPage: boolean 93 123 } 124 + edges: { 125 + starredAt: string 126 + }[] 94 127 } 95 - }>( 96 - `query($owner: String!, $repo: String!, $first: Int, $last: Int, $after: String, $before: String) { 128 + const response_promise = octokit.graphql<{ 129 + repository: { 130 + stargazers: Stargazers 131 + } 132 + }>( 133 + `query($owner: String!, $repo: String!, $first: Int, $last: Int, $after: String, $before: String) { 97 134 repository(owner: $owner, name: $repo) { 98 135 stargazers(first: $first, last: $last, after: $after, before: $before, orderBy: {field: STARRED_AT, direction: ASC}) { 99 136 totalCount ··· 108 145 } 109 146 } 110 147 } 111 - }`, 112 - { 113 - owner, 114 - repo, 115 - after: direction === 'forward' ? cursor : undefined, 116 - before: direction === 'back' ? cursor : undefined, 117 - first: direction === 'forward' ? 100 : undefined, 118 - last: direction === 'back' ? 100 : undefined, 119 - }, 120 - ) 148 + }`, 149 + { 150 + owner: this.owner, 151 + repo: this.repo, 152 + first: direction === 'forward' ? 100 : undefined, 153 + after: direction === 'forward' ? cursor.forward : undefined, 154 + last: direction === 'backward' ? 100 : undefined, 155 + before: direction === 'backward' ? cursor.backward : undefined, 156 + }, 157 + ) 158 + 159 + const response = await response_promise.catch((error) => { 160 + if (error instanceof GraphqlResponseError && error.errors) { 161 + return { 162 + error: error.errors.map((error) => error.message).join('/n'), 163 + } 164 + } else if (error instanceof Error) { 165 + return { error: `${error.name}: ${error.message}` } 166 + } else { 167 + return { error: "Couldn't fetch stargazers" } 168 + } 169 + }) 170 + 171 + console.log('response took', Date.now() - start_time, response) 172 + if ('error' in response) { 173 + return { error: response.error, stargazers: undefined } 174 + } 175 + 176 + // const stargazers_forwards = response.repository.stargazers_forwards 177 + // const stargazers_backwards = response.repository.stargazers_backwards 178 + 179 + // if (!this.backwards_count_initialised) { 180 + // this.backwards_count = stargazers_backwards.totalCount 181 + // this.backwards_count_initialised = true 182 + // } 183 + 184 + // const star_times_forwards = stargazers_forwards.edges 185 + // .filter((edge) => !this.user_ids.has(edge.user.id)) 186 + // .map((edge) => { 187 + // this.user_ids.add(edge.user.id) 188 + // this.forwards_count++ 189 + // return { 190 + // t: Math.floor(new Date(edge.starredAt).getTime() / 1000) as UTCTimestamp, 191 + // v: this.forwards_count, 192 + // } 193 + // }) 194 + 195 + // const star_times_backwards = stargazers_backwards.edges 196 + // .filter((edge) => !this.user_ids.has(edge.user.id)) 197 + // .map((edge) => { 198 + // this.user_ids.add(edge.user.id) 199 + // this.backwards_count!-- 200 + // return { 201 + // t: Math.floor(new Date(edge.starredAt).getTime() / 1000) as UTCTimestamp, 202 + // v: this.backwards_count!, 203 + // } 204 + // }) 205 + const stargazers = response.repository.stargazers 121 206 122 - const response = await response_promise.catch((error) => { 123 - if (error instanceof GraphqlResponseError && error.errors) { 207 + if (!this.backward_count_initialised) { 208 + this.backward_count = stargazers.totalCount + 1 209 + this.backward_count_initialised = true 210 + } 211 + 212 + const filtered_stargazers = stargazers.edges.filter((edge) => { 213 + // Stop once we reach overlapping data 214 + if (direction === 'forward') { 215 + return !this.backward_star_times.has(edge.starredAt) 216 + } else { 217 + return !this.forward_star_times.has(edge.starredAt) 218 + } 219 + }) 220 + if (direction === 'backward') { 221 + filtered_stargazers.reverse() // correctly handle backward_count-- 222 + } 223 + const star_times = filtered_stargazers.map((edge) => { 224 + if (direction === 'forward') { 225 + this.forward_star_times.add(edge.starredAt) 226 + this.forward_count++ 227 + } else { 228 + this.backward_star_times.add(edge.starredAt) 229 + this.backward_count-- 230 + } 124 231 return { 125 - error: error.errors.map((error) => error.message).join('/n'), 232 + t: Math.floor(new Date(edge.starredAt).getTime() / 1000) as UTCTimestamp, 233 + v: direction === 'forward' ? this.forward_count : this.backward_count!, 234 + } 235 + }) 236 + if (direction === 'backward') { 237 + star_times.reverse() // reset order 238 + } 239 + const has_overlapped = star_times.length !== stargazers.edges.length 240 + 241 + this.data_points.push(...star_times) 242 + this.data_points.sort((a, b) => a.t - b.t) 243 + 244 + if (has_overlapped) { 245 + this.next_cursor = null 246 + } else if (direction === 'forward' && stargazers.pageInfo.hasNextPage) { 247 + this.next_cursor = { 248 + forward: stargazers.pageInfo.endCursor, 249 + backward: this.next_cursor.backward, 250 + } 251 + } else if (direction === 'backward' && stargazers.pageInfo.hasPreviousPage) { 252 + this.next_cursor = { 253 + forward: this.next_cursor.forward, 254 + backward: stargazers.pageInfo.startCursor, 126 255 } 127 - } else if (error instanceof Error) { 128 - return { error: `${error.name}: ${error.message}` } 129 256 } else { 130 - return { error: "Couldn't fetch stargazers" } 257 + this.next_cursor = null 131 258 } 132 - }) 133 259 134 - console.log('response took', Date.now() - start_time, response) 135 - if ('error' in response) { 136 - return { error: response.error, stargazers: undefined } 137 - } else { 260 + this.total_count = Math.max( 261 + stargazers.totalCount, 262 + this.forward_star_times.size + this.backward_star_times.size, 263 + ) 264 + 138 265 return { 139 266 error: undefined, 140 267 stargazers: { 141 - ...response.repository.stargazers, 142 - starTimes: response.repository.stargazers.edges.map((edge) => edge.starredAt), 268 + direction, 269 + star_times_data: star_times, 143 270 }, 144 271 } 145 272 }