Spaces:
Running
Running
feat: period selector on Trends tab (week/month/3m/6m/12m/2y)
Browse files
app.py
CHANGED
|
@@ -674,14 +674,61 @@ with tab_trends:
|
|
| 674 |
st.subheader("π Trends Over Time")
|
| 675 |
st.caption("How government open source has evolved β new activity, rising tags, and shifting languages.")
|
| 676 |
|
| 677 |
-
# ----
|
| 678 |
-
|
| 679 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 680 |
|
| 681 |
-
cutoff_24m = (datetime.now(timezone.utc) - timedelta(days=730)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
| 682 |
conds_m, params_m = build_where(base_table="r")
|
| 683 |
conds_m.append("r.created_at >= ?")
|
| 684 |
-
params_m.append(
|
| 685 |
w_m = ("WHERE " + " AND ".join(conds_m)) if conds_m else ""
|
| 686 |
tj_m = "JOIN repository_tags rt ON r.html_url = rt.html_url" if sel_tags else ""
|
| 687 |
if sel_tags:
|
|
@@ -690,43 +737,68 @@ with tab_trends:
|
|
| 690 |
params_m.extend(sel_tags)
|
| 691 |
w_m = ("WHERE " + " AND ".join(conds_m)) if conds_m else ""
|
| 692 |
|
| 693 |
-
|
| 694 |
-
f"""SELECT
|
| 695 |
FROM repositories r {tj_m} {w_m}
|
| 696 |
-
GROUP BY
|
| 697 |
params_m,
|
| 698 |
)
|
| 699 |
-
|
| 700 |
-
if not
|
| 701 |
fig = px.bar(
|
| 702 |
-
|
| 703 |
-
labels={"
|
| 704 |
color="new_repos", color_continuous_scale="Blues",
|
| 705 |
)
|
| 706 |
-
fig.update_layout(coloraxis_showscale=False, xaxis_title=
|
| 707 |
st.plotly_chart(fig, use_container_width=True)
|
| 708 |
else:
|
| 709 |
-
st.info("Not enough data for
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 710 |
|
| 711 |
st.divider()
|
| 712 |
|
| 713 |
# ---- 2. Tag momentum ----
|
| 714 |
-
st.markdown("### π Tag Momentum")
|
| 715 |
st.caption(
|
| 716 |
-
"Tags ranked by growth β repos
|
| 717 |
"Higher ratio = faster-growing category."
|
| 718 |
)
|
| 719 |
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
cutoff_24m_str = (datetime.now(timezone.utc) - timedelta(days=730)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
| 723 |
|
| 724 |
-
conds_base, params_base = build_where(base_table="r")
|
| 725 |
-
w_base = (" AND ".join(conds_base)) if conds_base else ""
|
| 726 |
-
and_base = ("AND " + w_base) if w_base else ""
|
| 727 |
-
|
| 728 |
-
tf_sql_mom = _tag_filter_sql("rt.tag")
|
| 729 |
-
tf_params_mom = _tag_filter_params()
|
| 730 |
df_momentum = query_df(
|
| 731 |
f"""
|
| 732 |
SELECT
|
|
@@ -737,11 +809,11 @@ with tab_trends:
|
|
| 737 |
JOIN repositories r ON rt.html_url = r.html_url
|
| 738 |
WHERE r.created_at IS NOT NULL AND {tf_sql_mom} {and_base}
|
| 739 |
GROUP BY rt.tag
|
| 740 |
-
HAVING recent >=
|
| 741 |
ORDER BY (CAST(recent AS FLOAT) / prior) DESC
|
| 742 |
LIMIT 30
|
| 743 |
""",
|
| 744 |
-
[
|
| 745 |
)
|
| 746 |
|
| 747 |
if not df_momentum.empty:
|
|
@@ -751,7 +823,7 @@ with tab_trends:
|
|
| 751 |
col_m1, col_m2 = st.columns(2)
|
| 752 |
|
| 753 |
with col_m1:
|
| 754 |
-
st.markdown("**Fastest growing tags**
|
| 755 |
fig = px.bar(
|
| 756 |
df_momentum.head(20), x="growth_ratio", y="tag", orientation="h",
|
| 757 |
color="growth_ratio", color_continuous_scale="Greens",
|
|
@@ -766,7 +838,7 @@ with tab_trends:
|
|
| 766 |
st.plotly_chart(fig, use_container_width=True)
|
| 767 |
|
| 768 |
with col_m2:
|
| 769 |
-
st.markdown("**Top
|
| 770 |
df_recent_top = query_df(
|
| 771 |
f"""
|
| 772 |
SELECT rt.tag, COUNT(DISTINCT r.html_url) as recent_count
|
|
@@ -774,26 +846,27 @@ with tab_trends:
|
|
| 774 |
WHERE r.created_at >= ? AND {tf_sql_mom} {and_base}
|
| 775 |
GROUP BY rt.tag ORDER BY recent_count DESC LIMIT 20
|
| 776 |
""",
|
| 777 |
-
[
|
| 778 |
)
|
| 779 |
if not df_recent_top.empty:
|
| 780 |
fig2 = px.bar(
|
| 781 |
df_recent_top, x="recent_count", y="tag", orientation="h",
|
| 782 |
color="recent_count", color_continuous_scale="Purples",
|
| 783 |
-
labels={"recent_count": "Repos (
|
| 784 |
)
|
| 785 |
fig2.update_layout(
|
| 786 |
yaxis=dict(autorange="reversed"), height=550,
|
| 787 |
-
coloraxis_showscale=False,
|
|
|
|
| 788 |
)
|
| 789 |
st.plotly_chart(fig2, use_container_width=True)
|
| 790 |
|
| 791 |
# Emerging tags table
|
| 792 |
-
st.markdown("**Emerging tags** β ratio > 1.5
|
| 793 |
df_emerging = df_momentum[df_momentum["growth_ratio"] >= 1.5][
|
| 794 |
["tag", "recent", "prior", "growth_ratio", "growth_pct"]
|
| 795 |
].rename(columns={
|
| 796 |
-
"tag": "Tag", "recent":
|
| 797 |
"growth_ratio": "Ratio", "growth_pct": "Growth %"
|
| 798 |
})
|
| 799 |
if not df_emerging.empty:
|
|
@@ -801,7 +874,7 @@ with tab_trends:
|
|
| 801 |
else:
|
| 802 |
st.info("No tags with >50% growth in this period.")
|
| 803 |
else:
|
| 804 |
-
st.info("Not enough tagged data
|
| 805 |
|
| 806 |
st.divider()
|
| 807 |
|
|
@@ -829,7 +902,6 @@ with tab_trends:
|
|
| 829 |
df_lang_year = df_lang_year[df_lang_year["year"].str.match(r"^\d{4}$", na=False)]
|
| 830 |
|
| 831 |
if not df_lang_year.empty:
|
| 832 |
-
# Absolute count line chart
|
| 833 |
fig_lang = px.line(
|
| 834 |
df_lang_year, x="year", y="count", color="language",
|
| 835 |
labels={"year": "Year", "count": "New repositories", "language": "Language"},
|
|
@@ -838,7 +910,6 @@ with tab_trends:
|
|
| 838 |
fig_lang.update_layout(legend=dict(orientation="h", y=-0.25))
|
| 839 |
st.plotly_chart(fig_lang, use_container_width=True)
|
| 840 |
|
| 841 |
-
# Share / normalised stacked area
|
| 842 |
st.caption("As a share of all new repos that year (top 10 languages).")
|
| 843 |
df_totals = df_lang_year.groupby("year")["count"].sum().reset_index().rename(columns={"count": "total"})
|
| 844 |
df_share = df_lang_year.merge(df_totals, on="year")
|
|
|
|
| 674 |
st.subheader("π Trends Over Time")
|
| 675 |
st.caption("How government open source has evolved β new activity, rising tags, and shifting languages.")
|
| 676 |
|
| 677 |
+
# ---- Period selector ----
|
| 678 |
+
PERIOD_OPTIONS = {
|
| 679 |
+
"This week": 7,
|
| 680 |
+
"This month": 30,
|
| 681 |
+
"Last 3 months": 90,
|
| 682 |
+
"Last 6 months": 180,
|
| 683 |
+
"Last 12 months": 365,
|
| 684 |
+
"Last 2 years": 730,
|
| 685 |
+
}
|
| 686 |
+
period_label = st.radio(
|
| 687 |
+
"Period", list(PERIOD_OPTIONS.keys()), index=4,
|
| 688 |
+
horizontal=True, key="trends_period",
|
| 689 |
+
)
|
| 690 |
+
period_days = PERIOD_OPTIONS[period_label]
|
| 691 |
+
period_prior_days = period_days * 2 # prior window = same length, shifted back
|
| 692 |
+
|
| 693 |
+
now_utc = datetime.now(timezone.utc)
|
| 694 |
+
now_str = now_utc.strftime("%Y-%m-%dT%H:%M:%SZ")
|
| 695 |
+
cutoff_recent = (now_utc - timedelta(days=period_days)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
| 696 |
+
cutoff_prior = (now_utc - timedelta(days=period_prior_days)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
| 697 |
+
|
| 698 |
+
# Granularity: week/month buckets depending on period
|
| 699 |
+
if period_days <= 30:
|
| 700 |
+
bucket_fmt = "%Y-%W" # ISO week
|
| 701 |
+
bucket_label = "Week"
|
| 702 |
+
elif period_days <= 365:
|
| 703 |
+
bucket_fmt = "%Y-%m" # Month
|
| 704 |
+
bucket_label = "Month"
|
| 705 |
+
else:
|
| 706 |
+
bucket_fmt = "%Y-%m"
|
| 707 |
+
bucket_label = "Month"
|
| 708 |
+
|
| 709 |
+
# For the repos chart, show the bucket pattern for the selected period
|
| 710 |
+
# SQLite STRFTIME format
|
| 711 |
+
if period_days <= 30:
|
| 712 |
+
sql_bucket = "STRFTIME('%Y-%W', r.created_at)"
|
| 713 |
+
bucket_re = r"^\d{4}-\d{2}$"
|
| 714 |
+
else:
|
| 715 |
+
sql_bucket = "SUBSTR(r.created_at, 1, 7)"
|
| 716 |
+
bucket_re = r"^\d{4}-\d{2}$"
|
| 717 |
+
|
| 718 |
+
st.divider()
|
| 719 |
+
|
| 720 |
+
conds_base, params_base = build_where(base_table="r")
|
| 721 |
+
w_base = (" AND ".join(conds_base)) if conds_base else ""
|
| 722 |
+
and_base = ("AND " + w_base) if w_base else ""
|
| 723 |
+
tf_sql_mom = _tag_filter_sql("rt.tag")
|
| 724 |
+
tf_params_mom = _tag_filter_params()
|
| 725 |
+
|
| 726 |
+
# ---- 1. New repos per period ----
|
| 727 |
+
st.markdown(f"### ποΈ New Repositories β {period_label}")
|
| 728 |
|
|
|
|
| 729 |
conds_m, params_m = build_where(base_table="r")
|
| 730 |
conds_m.append("r.created_at >= ?")
|
| 731 |
+
params_m.append(cutoff_recent)
|
| 732 |
w_m = ("WHERE " + " AND ".join(conds_m)) if conds_m else ""
|
| 733 |
tj_m = "JOIN repository_tags rt ON r.html_url = rt.html_url" if sel_tags else ""
|
| 734 |
if sel_tags:
|
|
|
|
| 737 |
params_m.extend(sel_tags)
|
| 738 |
w_m = ("WHERE " + " AND ".join(conds_m)) if conds_m else ""
|
| 739 |
|
| 740 |
+
df_activity = query_df(
|
| 741 |
+
f"""SELECT {sql_bucket} as bucket, COUNT(DISTINCT r.html_url) as new_repos
|
| 742 |
FROM repositories r {tj_m} {w_m}
|
| 743 |
+
GROUP BY bucket ORDER BY bucket""",
|
| 744 |
params_m,
|
| 745 |
)
|
| 746 |
+
df_activity = df_activity[df_activity["bucket"].str.match(bucket_re, na=False)]
|
| 747 |
+
if not df_activity.empty:
|
| 748 |
fig = px.bar(
|
| 749 |
+
df_activity, x="bucket", y="new_repos",
|
| 750 |
+
labels={"bucket": bucket_label, "new_repos": "New repositories"},
|
| 751 |
color="new_repos", color_continuous_scale="Blues",
|
| 752 |
)
|
| 753 |
+
fig.update_layout(coloraxis_showscale=False, xaxis_title=bucket_label, yaxis_title="New repos")
|
| 754 |
st.plotly_chart(fig, use_container_width=True)
|
| 755 |
else:
|
| 756 |
+
st.info("Not enough data for this period.")
|
| 757 |
+
|
| 758 |
+
# Fastest growing repos this period (by stars delta proxy: recently created + high stars)
|
| 759 |
+
st.markdown(f"#### π Top New Repos β {period_label}")
|
| 760 |
+
st.caption("Highest-starred repositories created in the selected period.")
|
| 761 |
+
conds_nr, params_nr = build_where(base_table="r")
|
| 762 |
+
conds_nr.append("r.created_at >= ?")
|
| 763 |
+
params_nr.append(cutoff_recent)
|
| 764 |
+
w_nr = ("WHERE " + " AND ".join(conds_nr)) if conds_nr else ""
|
| 765 |
+
tj_nr = "JOIN repository_tags rt ON r.html_url = rt.html_url" if sel_tags else ""
|
| 766 |
+
if sel_tags:
|
| 767 |
+
ph = ",".join(["?"] * len(sel_tags))
|
| 768 |
+
conds_nr.append(f"rt.tag IN ({ph})")
|
| 769 |
+
params_nr.extend(sel_tags)
|
| 770 |
+
w_nr = ("WHERE " + " AND ".join(conds_nr)) if conds_nr else ""
|
| 771 |
+
df_new_repos = query_df(
|
| 772 |
+
f"""SELECT r.name, r.owner, r.country, r.language, r.stars, r.created_at, r.html_url
|
| 773 |
+
FROM repositories r {tj_nr} {w_nr}
|
| 774 |
+
GROUP BY r.html_url ORDER BY r.stars DESC LIMIT 20""",
|
| 775 |
+
params_nr,
|
| 776 |
+
)
|
| 777 |
+
if not df_new_repos.empty:
|
| 778 |
+
st.dataframe(
|
| 779 |
+
df_new_repos,
|
| 780 |
+
column_config={
|
| 781 |
+
"html_url": st.column_config.LinkColumn("URL", display_text="Open"),
|
| 782 |
+
"stars": st.column_config.NumberColumn("β Stars"),
|
| 783 |
+
"created_at": st.column_config.TextColumn("Created"),
|
| 784 |
+
},
|
| 785 |
+
use_container_width=True, hide_index=True,
|
| 786 |
+
)
|
| 787 |
+
else:
|
| 788 |
+
st.info("No new repos in this period.")
|
| 789 |
|
| 790 |
st.divider()
|
| 791 |
|
| 792 |
# ---- 2. Tag momentum ----
|
| 793 |
+
st.markdown(f"### π Tag Momentum β {period_label} vs prior {period_label.lower()}")
|
| 794 |
st.caption(
|
| 795 |
+
f"Tags ranked by growth β repos created in the selected period vs the equivalent period before it. "
|
| 796 |
"Higher ratio = faster-growing category."
|
| 797 |
)
|
| 798 |
|
| 799 |
+
# Minimum repo count scales with period to avoid noise on short windows
|
| 800 |
+
min_repos = max(2, period_days // 60)
|
|
|
|
| 801 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 802 |
df_momentum = query_df(
|
| 803 |
f"""
|
| 804 |
SELECT
|
|
|
|
| 809 |
JOIN repositories r ON rt.html_url = r.html_url
|
| 810 |
WHERE r.created_at IS NOT NULL AND {tf_sql_mom} {and_base}
|
| 811 |
GROUP BY rt.tag
|
| 812 |
+
HAVING recent >= {min_repos} AND prior >= {min_repos}
|
| 813 |
ORDER BY (CAST(recent AS FLOAT) / prior) DESC
|
| 814 |
LIMIT 30
|
| 815 |
""",
|
| 816 |
+
[cutoff_recent, cutoff_prior, cutoff_recent] + tf_params_mom + params_base,
|
| 817 |
)
|
| 818 |
|
| 819 |
if not df_momentum.empty:
|
|
|
|
| 823 |
col_m1, col_m2 = st.columns(2)
|
| 824 |
|
| 825 |
with col_m1:
|
| 826 |
+
st.markdown(f"**Fastest growing tags**")
|
| 827 |
fig = px.bar(
|
| 828 |
df_momentum.head(20), x="growth_ratio", y="tag", orientation="h",
|
| 829 |
color="growth_ratio", color_continuous_scale="Greens",
|
|
|
|
| 838 |
st.plotly_chart(fig, use_container_width=True)
|
| 839 |
|
| 840 |
with col_m2:
|
| 841 |
+
st.markdown(f"**Top tags by volume**")
|
| 842 |
df_recent_top = query_df(
|
| 843 |
f"""
|
| 844 |
SELECT rt.tag, COUNT(DISTINCT r.html_url) as recent_count
|
|
|
|
| 846 |
WHERE r.created_at >= ? AND {tf_sql_mom} {and_base}
|
| 847 |
GROUP BY rt.tag ORDER BY recent_count DESC LIMIT 20
|
| 848 |
""",
|
| 849 |
+
[cutoff_recent] + tf_params_mom + params_base,
|
| 850 |
)
|
| 851 |
if not df_recent_top.empty:
|
| 852 |
fig2 = px.bar(
|
| 853 |
df_recent_top, x="recent_count", y="tag", orientation="h",
|
| 854 |
color="recent_count", color_continuous_scale="Purples",
|
| 855 |
+
labels={"recent_count": f"Repos ({period_label.lower()})", "tag": "Tag"},
|
| 856 |
)
|
| 857 |
fig2.update_layout(
|
| 858 |
yaxis=dict(autorange="reversed"), height=550,
|
| 859 |
+
coloraxis_showscale=False,
|
| 860 |
+
xaxis_title=f"Repos ({period_label.lower()})", yaxis_title="",
|
| 861 |
)
|
| 862 |
st.plotly_chart(fig2, use_container_width=True)
|
| 863 |
|
| 864 |
# Emerging tags table
|
| 865 |
+
st.markdown("**Emerging tags** β ratio > 1.5")
|
| 866 |
df_emerging = df_momentum[df_momentum["growth_ratio"] >= 1.5][
|
| 867 |
["tag", "recent", "prior", "growth_ratio", "growth_pct"]
|
| 868 |
].rename(columns={
|
| 869 |
+
"tag": "Tag", "recent": period_label, "prior": f"Prior {period_label.lower()}",
|
| 870 |
"growth_ratio": "Ratio", "growth_pct": "Growth %"
|
| 871 |
})
|
| 872 |
if not df_emerging.empty:
|
|
|
|
| 874 |
else:
|
| 875 |
st.info("No tags with >50% growth in this period.")
|
| 876 |
else:
|
| 877 |
+
st.info("Not enough tagged data for this period β try a longer window.")
|
| 878 |
|
| 879 |
st.divider()
|
| 880 |
|
|
|
|
| 902 |
df_lang_year = df_lang_year[df_lang_year["year"].str.match(r"^\d{4}$", na=False)]
|
| 903 |
|
| 904 |
if not df_lang_year.empty:
|
|
|
|
| 905 |
fig_lang = px.line(
|
| 906 |
df_lang_year, x="year", y="count", color="language",
|
| 907 |
labels={"year": "Year", "count": "New repositories", "language": "Language"},
|
|
|
|
| 910 |
fig_lang.update_layout(legend=dict(orientation="h", y=-0.25))
|
| 911 |
st.plotly_chart(fig_lang, use_container_width=True)
|
| 912 |
|
|
|
|
| 913 |
st.caption("As a share of all new repos that year (top 10 languages).")
|
| 914 |
df_totals = df_lang_year.groupby("year")["count"].sum().reset_index().rename(columns={"count": "total"})
|
| 915 |
df_share = df_lang_year.merge(df_totals, on="year")
|