├── .eslintrc.json ├── .gitignore ├── .vscode └── settings.json ├── README.md ├── app ├── about │ └── page.tsx ├── action.tsx ├── actions.ts ├── actions │ ├── ai.ts │ ├── kpi.ts │ └── query.ts ├── dashboard │ └── page.tsx ├── favicon.ico ├── globals.css ├── layout.tsx ├── opengraph-image.png └── page.tsx ├── bun.lockb ├── components.json ├── components ├── CampaignFilter.tsx ├── Charts.tsx ├── Chat.tsx ├── ClearParams.tsx ├── DashCards │ ├── AudienceCard.tsx │ ├── ContentCard.tsx │ ├── EngagementCard.tsx │ ├── LocationDonut.tsx │ ├── PlatformCard.tsx │ └── index.tsx ├── Header.tsx ├── MapCard.tsx ├── PlatformFilter.tsx ├── ai │ ├── GoodOverBad.tsx │ ├── GrowthRate.tsx │ └── SubsOverTime.tsx ├── charts │ ├── BarChart.tsx │ ├── BarListChart.tsx │ ├── DonutChart.tsx │ ├── LineChart.tsx │ ├── MapChart.tsx │ ├── TableChart.tsx │ ├── rawBarList.tsx │ ├── scatterChart.tsx │ ├── sparkChart.tsx │ └── world_countries.json ├── chat-list.tsx ├── dashboard.tsx ├── empty-screen.tsx ├── kpi │ ├── BudgetCard.tsx │ ├── ClicksCard.tsx │ ├── ImpressionCard.tsx │ ├── RevenueCard.tsx │ ├── SubscriberCard.tsx │ └── index.tsx ├── llm-charts │ ├── AreaChartComponent.tsx │ ├── AreaSkeleton.tsx │ ├── BarChartComponent.tsx │ ├── DonutChartComponent.tsx │ ├── LineChartComponent.tsx │ ├── NumberChartComponent.tsx │ ├── NumberSkeleton.tsx │ ├── ScatterChartComponent.tsx │ ├── TableChartComponent.tsx │ ├── index.tsx │ └── markdown.tsx ├── message.tsx └── ui │ ├── avatar.tsx │ ├── badge.tsx │ ├── button.tsx │ ├── card.tsx │ ├── combobox.tsx │ ├── command.tsx │ ├── dialog.tsx │ ├── dropdown-menu.tsx │ ├── icons.tsx │ ├── input.tsx │ ├── popover.tsx │ ├── scroll-area.tsx │ ├── select.tsx │ ├── separator.tsx │ ├── sheet.tsx │ ├── skeleton.tsx │ ├── sparkles.tsx │ ├── spinner.tsx │ ├── table.tsx │ └── tooltip.tsx ├── drizzle.config.ts ├── lib ├── db.ts ├── execute.ts ├── hooks │ ├── chat-scroll-anchor.tsx │ ├── use-at-bottom.tsx │ └── use-enter-submit.tsx ├── redis.ts ├── supabase │ ├── browser.ts │ └── server.ts ├── tool-definition.ts ├── utils.ts └── validation │ └── index.ts ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── line-chart.png ├── next.svg └── vercel.svg ├── supabase ├── .gitignore ├── config.toml ├── functions │ └── .vscode │ │ ├── extensions.json │ │ └── settings.json └── migrations │ └── 20240407221836_remote_schema.sql ├── tailwind.config.ts ├── tsconfig.json └── types ├── database.types.ts └── global.d.ts /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | .env.local 39 | .env.* -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "workbench.colorCustomizations": { 3 | "activityBar.activeBackground": "#ac6cfe", 4 | "activityBar.background": "#ac6cfe", 5 | "activityBar.foreground": "#15202b", 6 | "activityBar.inactiveForeground": "#15202b99", 7 | "activityBarBadge.background": "#fec9a0", 8 | "activityBarBadge.foreground": "#15202b", 9 | "commandCenter.border": "#e7e7e799", 10 | "sash.hoverBorder": "#ac6cfe", 11 | "statusBar.background": "#903afd", 12 | "statusBar.foreground": "#e7e7e7", 13 | "statusBarItem.hoverBackground": "#ac6cfe", 14 | "statusBarItem.remoteBackground": "#903afd", 15 | "statusBarItem.remoteForeground": "#e7e7e7", 16 | "titleBar.activeBackground": "#903afd", 17 | "titleBar.activeForeground": "#e7e7e7", 18 | "titleBar.inactiveBackground": "#903afd99", 19 | "titleBar.inactiveForeground": "#e7e7e799" 20 | }, 21 | "peacock.color": "#903afd", 22 | "editor.fontSize": 10 23 | } 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 37 | -------------------------------------------------------------------------------- /app/about/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import React from "react"; 3 | 4 | function Keyword({ children }: { children: React.ReactNode }) { 5 | return ( 6 | 7 | {children} 8 | 9 | ); 10 | } 11 | 12 | export default function AboutPage() { 13 | return ( 14 |
15 |

16 | Data Visualization for{" "} 17 | Campaign and{" "} 18 | Subscriber Analysis 19 |

20 |

21 | Our project focuses on data visualization for 22 | analyzing campaign and subscriber{" "} 23 | datasets. Through interactive visualizations, we aim 24 | to uncover insights and patterns{" "} 25 | that drive campaign success and subscriber engagement. By leveraging{" "} 26 | data analytics and{" "} 27 | visualization techniques, we empower businesses to 28 | make data-driven decisions and optimize their{" "} 29 | marketing strategies. 30 |

31 |

Who We Are

32 |

33 | We are a team of four data professionals based in{" "} 34 | Auckland, NZ. The team members 35 | are{" "} 36 | 41 | 42 | Kaarthik Andavar 43 | 44 | 45 | ,{" "} 46 | 51 | 52 | Olivia Yang 53 | 54 | 55 | ,{" "} 56 | 61 | 62 | Mohit Saini 63 | 64 | 65 | , and{" "} 66 | 71 | 72 | Ding Wang 73 | 74 | 75 | . We are dedicated to harnessing the power of data for{" "} 76 | actionable insights. With expertise in{" "} 77 | data engineering, analysis, and{" "} 78 | visualization, we bring a unique blend of skills to 79 | transform complex datasets into{" "} 80 | meaningful visual narratives. Our passion lies in 81 | helping businesses unlock the full potential of their data and drive 82 | growth through informed strategies. 83 |

84 |
85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /app/action.tsx: -------------------------------------------------------------------------------- 1 | import "server-only"; 2 | 3 | import { createAI, createStreamableUI, getMutableAIState } from "ai/rsc"; 4 | import OpenAI from "openai"; 5 | 6 | import { GoodOverBad } from "@/components/ai/GoodOverBad"; 7 | import { GrowthRateChartCard } from "@/components/ai/GrowthRate"; 8 | import { SubsOverTimeCard } from "@/components/ai/SubsOverTime"; 9 | import { Chart } from "@/components/llm-charts"; 10 | import AreaSkeleton from "@/components/llm-charts/AreaSkeleton"; 11 | import { BotCard, BotMessage } from "@/components/message"; 12 | import { spinner } from "@/components/ui/spinner"; 13 | import { runQuery } from "@/lib/db"; 14 | import { runOpenAICompletion } from "@/lib/utils"; 15 | import { FQueryResponse } from "@/lib/validation"; 16 | import { Code } from "bright"; 17 | import { format as sql_format } from "sql-formatter"; 18 | import { z } from "zod"; 19 | import { GoodOverBadquery } from "./actions/query"; 20 | 21 | const openai = new OpenAI({ 22 | apiKey: process.env.OPENAI_API_KEY || "", 23 | baseURL: `https://gateway.ai.cloudflare.com/v1/${process.env.CLOUDFLARE_ACCOUNT_TAG}/snowbrain/openai`, 24 | }); 25 | 26 | type OpenAIQueryResponse = z.infer; 27 | 28 | export interface QueryResult { 29 | columns: string[]; 30 | data: Array<{ [key: string]: any }>; 31 | } 32 | 33 | async function submitUserMessage(content: string) { 34 | "use server"; 35 | 36 | const aiState = getMutableAIState(); 37 | aiState.update([ 38 | ...aiState.get(), 39 | { 40 | role: "user", 41 | content, 42 | }, 43 | ]); 44 | 45 | const reply = createStreamableUI( 46 | {spinner} 47 | ); 48 | 49 | const completion = runOpenAICompletion(openai, { 50 | model: "gpt-4o-mini", 51 | stream: true, 52 | messages: [ 53 | { 54 | role: "system", 55 | content: ` 56 | You are a friendly AI assistant. You can help users with their queries and provide information about the two datasets. 57 | 58 | dataset 1 is about campaign - 59 | sample data \n 60 | CampaignID StartDate EndDate Budget Platform ContentID ContentType AudienceType Impressions Clicks NewSubscriptions Subscription Cost Revenue 61 | 1000 07.01.2023 22.02.2023 5904.29 Instagram 74308 Movie Families 80886 1909 870 10 8700 62 | 63 | \n 64 | dataset 2 is about Subscribers - 65 | Sample data \n 66 | SubscriberID CampaignID SubscriptionDate Age Gender Location AudienceType Satisfaction SubscriptionDays EngagementRate ViewingTime 67 | S00001 1000 19.01.2023 60 Male Asia Families Very Satisfied 376 3 3008 68 | 69 | These are the two tables that you can use to answer user queries, which is stored in postgresql supabase database. Preserve case for the column names. 70 | 71 | create table 72 | public.campaign ( 73 | "CampaignID" bigint not null, 74 | "StartDate" text null, 75 | "EndDate" text null, 76 | "Budget" double precision null, 77 | "Platform" text null, 78 | "ContentID" bigint null, 79 | "ContentType" text null, 80 | "AudienceType" text null, 81 | "Impressions" bigint null, 82 | "Clicks" bigint null, 83 | "NewSubscriptions" bigint null, 84 | "Subscription Cost" bigint null, 85 | "Revenue" bigint null, 86 | constraint campaign_pkey primary key ("CampaignID") 87 | ) tablespace pg_default; 88 | 89 | create table 90 | public.subscriber ( 91 | "SubscriberID" text not null, 92 | "CampaignID" bigint null, 93 | "SubscriptionDate" text null, 94 | "Age" bigint null, 95 | "Gender" text null, 96 | "Location" text null, 97 | "AudienceType" text null, 98 | "Satisfaction" text null, 99 | "SubscriptionDays" bigint null, 100 | "EngagementRate" bigint null, 101 | "ViewingTime" bigint null, 102 | constraint subscriber_pkey primary key ("SubscriberID"), 103 | constraint public_subscriber_CampaignID_fkey foreign key ("CampaignID") references campaign ("CampaignID") 104 | ) tablespace pg_default; 105 | 106 | You can only read data and make analysis, you cannot write or update data at any cost. 107 | 108 | if they ask about "Show me Subscribers growth over time?" then call the function \`growth_card\` to show the growth rate over time. 109 | if they ask about "Give me number of subscriptions over time? using line chart" then call the function \`subs_card\` to show the number of subscribers over time. 110 | if they ask about "Could you count good vs bad campaigns?" then call the function \`good_vs_bad_campaign\` to show the number of good vs bad campaigns using a table. 111 | 112 | Messages inside [] means that it's a UI element or a user event. For example: 113 | - "[Showing Subscribers growth over time card- using line chart]" means that the UI is showing a card with the title "Subscribers growth over time". 114 | - "[Showing number of subscribers over time (monthly) card- using line chart]" means that the UI is showing a card with the title "Number of subscribers over time". 115 | - "[Results for query: query with format: format and title: title and description: description. with data" means that a chart/table/number card is shown to that user. 116 | 117 | 118 | `, 119 | }, 120 | ...aiState.get().map((info: any) => ({ 121 | role: info.role, 122 | content: info.content, 123 | name: info.name, 124 | })), 125 | ], 126 | functions: [ 127 | { 128 | name: "query_data", 129 | description: 130 | "Query the data from the snowflake database and return the results.", 131 | parameters: FQueryResponse, 132 | }, 133 | { 134 | name: "growth_card", 135 | description: 136 | "Show the growth rate of subscribers over time using a line chart.", 137 | parameters: z.object({ 138 | month: z.string().optional(), 139 | }), 140 | }, 141 | { 142 | name: "subs_card", 143 | description: 144 | "Show the number of subscribers over time using a line chart.", 145 | parameters: z.object({ 146 | month: z.string().optional(), 147 | }), 148 | }, 149 | { 150 | name: "good_vs_bad_campaign", 151 | description: "Show the number of good vs bad campaigns using a table.", 152 | parameters: z.object({ 153 | month: z.string().optional(), 154 | }), 155 | }, 156 | ], 157 | temperature: 0, 158 | }); 159 | 160 | completion.onTextContent((content: string, isFinal: boolean) => { 161 | // console.log(content); 162 | 163 | reply.update({content}); 164 | if (isFinal) { 165 | reply.done(); 166 | aiState.done([...aiState.get(), { role: "assistant", content }]); 167 | } 168 | }); 169 | 170 | completion.onFunctionCall("growth_card", async () => { 171 | reply.update( 172 | 173 | 174 | 175 | ); 176 | 177 | reply.done( 178 | 179 | 180 | 181 | ); 182 | 183 | aiState.done([ 184 | ...aiState.get(), 185 | { 186 | role: "function", 187 | name: "growth_card", 188 | content: `[Snowflake query results for code: Showing Subscribers growth over time card- using line chart]`, 189 | }, 190 | ]); 191 | }); 192 | 193 | completion.onFunctionCall("subs_card", async () => { 194 | reply.update( 195 | 196 | 197 | 198 | ); 199 | 200 | reply.done( 201 | 202 | 203 | 204 | ); 205 | 206 | aiState.done([ 207 | ...aiState.get(), 208 | { 209 | role: "function", 210 | name: "subs_card", 211 | content: `[Snowflake query results for code: Showing number of subscribers over time (monthly) card- using line chart]`, 212 | }, 213 | ]); 214 | }); 215 | 216 | completion.onFunctionCall("good_vs_bad_campaign", async () => { 217 | reply.update( 218 | 219 | 220 | 221 | ); 222 | 223 | reply.done( 224 | 225 | 226 | 227 | ); 228 | 229 | aiState.done([ 230 | ...aiState.get(), 231 | { 232 | role: "function", 233 | name: "good_vs_bad_campaign", 234 | content: `[Snowflake query results for code: Showing number of good vs bad campaigns using a table] - the sql query used was ${GoodOverBadquery} and the data is ${goodBadData}`, 235 | }, 236 | ]); 237 | }); 238 | 239 | completion.onFunctionCall( 240 | "query_data", 241 | async (input: OpenAIQueryResponse) => { 242 | reply.update( 243 | 244 | 245 | 246 | ); 247 | const { format, title, timeField, categories, index, yaxis, size } = 248 | input; 249 | // console.log("Received timeField:", timeField); 250 | // console.log("Received format:", format); 251 | // console.log("Received title:", title); 252 | // console.log("Received categories:", categories); 253 | // console.log("Received index:", index); 254 | // console.log("Received yaxis:", yaxis); 255 | // console.log("Received size:", size); 256 | let query = input.query; 257 | 258 | const format_query = sql_format(query, { language: "sql" }); 259 | const res = await runQuery(format_query); 260 | // const res = testquery; 261 | const compatibleQueryResult: QueryResult = { 262 | columns: res.columns, 263 | data: res.data, 264 | }; 265 | 266 | reply.done( 267 | 268 |
269 | 279 |
280 | 281 | {format_query} 282 | 283 |
284 |
285 |
286 | ); 287 | 288 | aiState.done([ 289 | ...aiState.get(), 290 | { 291 | role: "function", 292 | name: "query_data", 293 | content: `[Snowflake query results for code: ${query} and chart format: ${format} with categories: ${categories} and data ${res.columns} ${res.data}]`, 294 | }, 295 | ]); 296 | } 297 | ); 298 | return { 299 | id: Date.now(), 300 | display: reply.value, 301 | }; 302 | } 303 | 304 | const initialAIState: { 305 | role: "user" | "assistant" | "system" | "function"; 306 | content: string; 307 | id?: string; 308 | name?: string; 309 | }[] = []; 310 | 311 | const initialUIState: { 312 | id: number; 313 | display: React.ReactNode; 314 | }[] = []; 315 | 316 | export const AI = createAI({ 317 | actions: { 318 | submitUserMessage, 319 | }, 320 | initialUIState, 321 | initialAIState, 322 | }); 323 | 324 | const goodBadData = [ 325 | { 326 | CampaignType: "Good Campaign", 327 | CampaignCount: 7, 328 | }, 329 | { 330 | CampaignType: "Bad Campaign", 331 | CampaignCount: 43, 332 | }, 333 | ]; 334 | -------------------------------------------------------------------------------- /app/actions/ai.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { runQuery } from "@/lib/db"; 4 | import { format } from "date-fns"; 5 | import { revalidatePath } from "next/cache"; 6 | import { 7 | CampaignData, 8 | SubscriberData, 9 | fetchCampaignData, 10 | fetchSubscriberData, 11 | } from "./kpi"; 12 | import { GoodOverBadquery } from "./query"; 13 | 14 | interface SubscriptionsOverTimeData { 15 | Month: string; 16 | NewSubscriptions: number; 17 | } 18 | function groupSubscribersByMonth(subscriberData: SubscriberData[]): { 19 | [month: string]: number; 20 | } { 21 | const result: { [monthKey: string]: number } = {}; 22 | 23 | subscriberData.forEach((subscriber) => { 24 | const subscriptionDate = new Date(subscriber.SubscriptionDate); 25 | const formattedMonth = format(subscriptionDate, "yyyy-MM"); 26 | 27 | if (!result[formattedMonth]) { 28 | result[formattedMonth] = 0; 29 | } 30 | // Increment subscriber count for the month 31 | result[formattedMonth]++; 32 | }); 33 | 34 | return result; 35 | } 36 | 37 | export async function fetchSubscriptionsOverTime( 38 | audience: string | null, 39 | contentType: string | null, 40 | month: string | null = "all", 41 | satisfaction: string | null 42 | ): Promise { 43 | const subscriberData = await fetchSubscriberData( 44 | satisfaction || null, 45 | audience, 46 | null, 47 | null, 48 | month 49 | ); 50 | 51 | const subscribersByMonth = groupSubscribersByMonth(subscriberData); 52 | 53 | // Get all unique months from the subscriber data 54 | const allMonths = Array.from( 55 | new Set( 56 | subscriberData.map((subscriber) => 57 | format(new Date(subscriber.SubscriptionDate), "yyyy-MM") 58 | ) 59 | ) 60 | ).sort(); 61 | 62 | // Calculate new subscriptions for each month 63 | const subscriptionsOverTime: SubscriptionsOverTimeData[] = allMonths.map( 64 | (month) => ({ 65 | Month: month, 66 | NewSubscriptions: subscribersByMonth[month] || 0, 67 | }) 68 | ); 69 | 70 | return subscriptionsOverTime; 71 | } 72 | 73 | interface GrowthRateOverTimeData { 74 | Month: string; 75 | GrowthRate: number; 76 | } 77 | 78 | export async function calculateGrowthRateForChart( 79 | monthlySubscriptions: SubscriptionsOverTimeData[] 80 | ): Promise { 81 | let growthData: GrowthRateOverTimeData[] = monthlySubscriptions.map( 82 | (data, index) => { 83 | if (index === 0) { 84 | return { Month: data.Month, GrowthRate: 0 }; // Initial month has no growth rate 85 | } 86 | const prevSubscriptions = 87 | monthlySubscriptions[index - 1].NewSubscriptions; 88 | const currentSubscriptions = data.NewSubscriptions; 89 | const growthRate = 90 | prevSubscriptions === 0 91 | ? 0 92 | : ((currentSubscriptions - prevSubscriptions) / prevSubscriptions) * 93 | 100; 94 | return { 95 | Month: data.Month, 96 | GrowthRate: parseFloat(growthRate.toFixed(2)), 97 | }; // Round to 2 decimal places 98 | } 99 | ); 100 | return growthData; 101 | } 102 | 103 | export async function GoodOverBadCampaign() { 104 | const result = await runQuery(GoodOverBadquery); 105 | return result; 106 | } 107 | -------------------------------------------------------------------------------- /app/actions/query.ts: -------------------------------------------------------------------------------- 1 | export const GoodOverBadquery = ` 2 | WITH AvgCampaignMetrics AS ( 3 | SELECT 4 | AVG(c."Impressions") AS "AvgImpressions", 5 | AVG(c."Clicks") AS "AvgClicks", 6 | AVG(c."NewSubscriptions") AS "AvgNewSubscriptions", 7 | AVG(c."Revenue") AS "AvgRevenue", 8 | AVG(c."Subscription Cost") AS "AvgSubscriptionCost", 9 | AVG(c."Revenue") - AVG(c."Budget") AS "AvgROI" 10 | FROM 11 | Campaign c 12 | ), 13 | 14 | CampaignMetrics AS ( 15 | SELECT 16 | c."CampaignID", 17 | c."Impressions", 18 | c."Clicks", 19 | c."NewSubscriptions", 20 | c."Revenue", 21 | c."Subscription Cost", 22 | (c."Revenue" - c."Budget") AS "ROI", 23 | acm."AvgImpressions", 24 | acm."AvgClicks", 25 | acm."AvgNewSubscriptions", 26 | acm."AvgRevenue", 27 | acm."AvgSubscriptionCost", 28 | acm."AvgROI" 29 | FROM 30 | Campaign c 31 | CROSS JOIN 32 | AvgCampaignMetrics acm 33 | ), 34 | 35 | CategorizedCampaigns AS ( 36 | SELECT 37 | cm."CampaignID", 38 | cm."Impressions", 39 | cm."Clicks", 40 | cm."NewSubscriptions", 41 | cm."Revenue", 42 | cm."Subscription Cost", 43 | cm."ROI", 44 | CASE 45 | WHEN cm."Impressions" > cm."AvgImpressions" 46 | AND cm."Clicks" > cm."AvgClicks" 47 | AND cm."NewSubscriptions" > cm."AvgNewSubscriptions" 48 | AND cm."Revenue" > cm."AvgRevenue" 49 | THEN 'Good Campaign' 50 | ELSE 'Bad Campaign' 51 | END AS "CampaignType" 52 | FROM 53 | CampaignMetrics cm 54 | ) 55 | 56 | SELECT 57 | cc."CampaignType", 58 | COUNT(*) AS "CampaignCount" 59 | FROM 60 | CategorizedCampaigns cc 61 | GROUP BY 62 | cc."CampaignType"; 63 | 64 | `; 65 | -------------------------------------------------------------------------------- /app/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | import { Dashboard } from "@/components/dashboard"; 2 | import { Suspense } from "react"; 3 | import { 4 | fetchAgeDistributionByLocation, 5 | fetchAudienceData, 6 | fetchContentData, 7 | fetchEngagementData, 8 | fetchPlatformData, 9 | fetchSubscribersByLocation, 10 | } from "../actions"; 11 | import { 12 | fetchBudgetData, 13 | fetchClicksData, 14 | fetchImpressionData, 15 | fetchRevenueData, 16 | fetchSubsData, 17 | } from "../actions/kpi"; 18 | 19 | export type SearchParams = { 20 | month?: string | "all"; 21 | audience?: string | null; 22 | contentType?: string | null; 23 | satisfaction?: string | null; 24 | location?: string | null; 25 | age?: string | null; 26 | platform?: string | null; 27 | campaignId?: string | null; 28 | }; 29 | 30 | // export const runtime = "edge"; 31 | 32 | export default async function Home({ 33 | searchParams, 34 | }: { 35 | searchParams: Promise; 36 | }) { 37 | const resolvedSearchParams = await searchParams; 38 | 39 | const { 40 | month, 41 | audience, 42 | contentType, 43 | satisfaction, 44 | location, 45 | age, 46 | platform, 47 | campaignId, 48 | } = resolvedSearchParams; 49 | 50 | const [ 51 | AudienceData, 52 | ContentData, 53 | subscribersByLocation, 54 | ageDistributionByLocation, 55 | RevenueData, 56 | BudgetData, 57 | ClicksData, 58 | ImpressionData, 59 | SubsData, 60 | EngagementData, 61 | PlatformData, 62 | ] = await Promise.all([ 63 | fetchAudienceData( 64 | month, 65 | audience, 66 | contentType, 67 | satisfaction, 68 | location, 69 | age, 70 | platform, 71 | campaignId 72 | ), 73 | fetchContentData( 74 | month, 75 | audience, 76 | contentType, 77 | satisfaction, 78 | location, 79 | age, 80 | platform, 81 | campaignId 82 | ), 83 | fetchSubscribersByLocation( 84 | month, 85 | audience, 86 | contentType, 87 | satisfaction, 88 | location, 89 | age, 90 | platform, 91 | campaignId 92 | ), 93 | fetchAgeDistributionByLocation( 94 | month, 95 | audience, 96 | contentType, 97 | satisfaction, 98 | location, 99 | age, 100 | platform, 101 | campaignId 102 | ), 103 | fetchRevenueData( 104 | month, 105 | audience, 106 | contentType, 107 | satisfaction, 108 | location, 109 | age, 110 | platform, 111 | campaignId 112 | ), 113 | fetchBudgetData( 114 | month, 115 | audience, 116 | contentType, 117 | satisfaction, 118 | location, 119 | age, 120 | platform, 121 | campaignId 122 | ), 123 | fetchClicksData( 124 | month, 125 | audience, 126 | contentType, 127 | satisfaction, 128 | location, 129 | age, 130 | platform, 131 | campaignId 132 | ), 133 | fetchImpressionData( 134 | month, 135 | audience, 136 | contentType, 137 | satisfaction, 138 | location, 139 | age, 140 | platform, 141 | campaignId 142 | ), 143 | fetchSubsData( 144 | month, 145 | audience, 146 | contentType, 147 | satisfaction, 148 | location, 149 | age, 150 | platform, 151 | campaignId 152 | ), 153 | fetchEngagementData( 154 | month, 155 | audience, 156 | contentType, 157 | satisfaction, 158 | location, 159 | age, 160 | platform, 161 | campaignId 162 | ), 163 | fetchPlatformData( 164 | month, 165 | audience, 166 | contentType, 167 | satisfaction, 168 | location, 169 | age, 170 | platform, 171 | campaignId 172 | ), 173 | ]); 174 | 175 | return ( 176 |
177 | }> 178 | 192 | 193 |
194 | ); 195 | } 196 | 197 | async function DashSkeleton() { 198 | return ( 199 |
200 |
201 | Hold on... 202 |
203 |
204 | ); 205 | } 206 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kaarthik108/supa-dash/b3a8d7321cda8764258a51244355e623cbbb13a9/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 222.2 84% 4.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 84% 4.9%; 15 | 16 | --primary: 222.2 47.4% 11.2%; 17 | --primary-foreground: 210 40% 98%; 18 | 19 | --secondary: 210 40% 96.1%; 20 | --secondary-foreground: 222.2 47.4% 11.2%; 21 | 22 | --muted: 210 40% 96.1%; 23 | --muted-foreground: 215.4 16.3% 46.9%; 24 | 25 | --accent: 210 40% 96.1%; 26 | --accent-foreground: 222.2 47.4% 11.2%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 210 40% 98%; 30 | 31 | --border: 214.3 31.8% 91.4%; 32 | --input: 214.3 31.8% 91.4%; 33 | --ring: 222.2 84% 4.9%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | 38 | .dark { 39 | --background: 222.2 84% 4.9%; 40 | --foreground: 210 40% 98%; 41 | 42 | --card: 222.2 84% 4.9%; 43 | --card-foreground: 210 40% 98%; 44 | 45 | --popover: 222.2 84% 4.9%; 46 | --popover-foreground: 210 40% 98%; 47 | 48 | --primary: 210 40% 98%; 49 | --primary-foreground: 222.2 47.4% 11.2%; 50 | 51 | --secondary: 217.2 32.6% 17.5%; 52 | --secondary-foreground: 210 40% 98%; 53 | 54 | --muted: 217.2 32.6% 17.5%; 55 | --muted-foreground: 215 20.2% 65.1%; 56 | 57 | --accent: 217.2 32.6% 17.5%; 58 | --accent-foreground: 210 40% 98%; 59 | 60 | --destructive: 0 62.8% 30.6%; 61 | --destructive-foreground: 210 40% 98%; 62 | 63 | --border: 217.2 32.6% 17.5%; 64 | --input: 217.2 32.6% 17.5%; 65 | --ring: 212.7 26.8% 83.9%; 66 | } 67 | } 68 | 69 | @layer base { 70 | * { 71 | @apply border-border; 72 | } 73 | body { 74 | @apply bg-background text-foreground; 75 | } 76 | } 77 | 78 | .custom-scrollbar::-webkit-scrollbar { 79 | width: 0.5rem; 80 | height: 0.5rem; 81 | } 82 | 83 | .custom-scrollbar::-webkit-scrollbar-thumb { 84 | background-color: transparent; 85 | border-radius: 0.25rem; 86 | } 87 | 88 | .custom-scrollbar::-webkit-scrollbar-track { 89 | background-color: transparent; 90 | } 91 | 92 | .custom-scrollbar:hover::-webkit-scrollbar-thumb { 93 | background-color: rgba(0, 0, 0, 0.2); 94 | } 95 | 96 | .custom-scrollbar:hover::-webkit-scrollbar-track { 97 | background-color: rgba(0, 0, 0, 0.1); 98 | } 99 | 100 | @keyframes fade-up { 101 | from { 102 | opacity: 0; 103 | transform: translateY(16px); 104 | } 105 | to { 106 | opacity: 1; 107 | transform: translateY(0px); 108 | } 109 | } 110 | 111 | @keyframes fade-right { 112 | from { 113 | opacity: 0; 114 | transform: translateX(-16px); 115 | } 116 | to { 117 | opacity: 1; 118 | transform: translateX(0px); 119 | } 120 | } 121 | 122 | .animate-fade-up { 123 | animation: fade-up 800ms cubic-bezier(0.34, 1.56, 0.64, 1); 124 | } 125 | 126 | .animate-fade-right { 127 | animation: fade-right 800ms cubic-bezier(0.34, 1.56, 0.64, 1); 128 | } 129 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { TopHeader } from "@/components/Header"; 2 | import { IconGitHub } from "@/components/ui/icons"; 3 | import { Analytics } from "@vercel/analytics/react"; 4 | import type { Metadata } from "next"; 5 | import { Roboto } from "next/font/google"; 6 | import { Suspense } from "react"; 7 | import { AI } from "./action"; 8 | import "./globals.css"; 9 | 10 | const robo = Roboto({ 11 | weight: ["100", "400", "700"], 12 | subsets: ["latin"], 13 | }); 14 | 15 | export const metadata: Metadata = { 16 | title: "Hack-Dash", 17 | description: "Hack-Dash is a dashboard built on top of Next.js and Supabase", 18 | metadataBase: new URL("https://supa-dash.vercel.app"), 19 | robots: { 20 | index: true, 21 | follow: true, 22 | googleBot: { 23 | index: true, 24 | follow: true, 25 | "max-video-preview": -1, 26 | "max-image-preview": "large", 27 | "max-snippet": -1, 28 | }, 29 | }, 30 | twitter: { 31 | title: "Hack-Dash", 32 | card: "summary_large_image", 33 | }, 34 | }; 35 | 36 | export default function RootLayout({ 37 | children, 38 | }: { 39 | children: React.ReactNode; 40 | }) { 41 | return ( 42 | 43 | 44 | 45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 | 58 |
59 | 62 | Loading.... 63 |
64 | } 65 | > 66 | {" "} 67 | {children} 68 | 69 | 70 | 71 | 101 | 102 | 103 | 104 | ); 105 | } 106 | -------------------------------------------------------------------------------- /app/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kaarthik108/supa-dash/b3a8d7321cda8764258a51244355e623cbbb13a9/app/opengraph-image.png -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | 3 | // export const runtime = "edge"; 4 | 5 | export default async function Home() { 6 | redirect("/dashboard/?month=all"); 7 | } 8 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kaarthik108/supa-dash/b3a8d7321cda8764258a51244355e623cbbb13a9/bun.lockb -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /components/CampaignFilter.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { fetchCampaignIds } from "@/app/actions"; 3 | import { Button } from "@/components/ui/button"; 4 | import { 5 | Command, 6 | CommandEmpty, 7 | CommandGroup, 8 | CommandInput, 9 | CommandItem, 10 | } from "@/components/ui/command"; 11 | import { 12 | Popover, 13 | PopoverContent, 14 | PopoverTrigger, 15 | } from "@/components/ui/popover"; 16 | import { ScrollArea } from "@/components/ui/scroll-area"; 17 | import { cn } from "@/lib/utils"; 18 | import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"; 19 | import { usePathname, useRouter, useSearchParams } from "next/navigation"; 20 | import * as React from "react"; 21 | import { useCallback, useEffect, useState } from "react"; 22 | 23 | export function CampaignFilter() { 24 | const pathname = usePathname(); 25 | const [open, setOpen] = useState(false); 26 | const router = useRouter(); 27 | const searchParams = useSearchParams(); 28 | const month = searchParams.get("month") || "all"; 29 | const audience = searchParams.get("audience") || null; 30 | const contentType = searchParams.get("contentType") || null; 31 | const satisfaction = searchParams.get("satisfaction") || null; 32 | const location = searchParams.get("location") || null; 33 | const age = searchParams.get("age") || null; 34 | const platform = searchParams.get("platform") || null; 35 | 36 | const selectedCampaignId = searchParams.get("campaignId") || null; 37 | const [campaignIds, setCampaignIds] = useState([]); 38 | const [searchQuery, setSearchQuery] = useState(""); 39 | 40 | useEffect(() => { 41 | const fetchData = async () => { 42 | const result = (await fetchCampaignIds( 43 | month, 44 | audience, 45 | contentType, 46 | satisfaction, 47 | location, 48 | age, 49 | platform 50 | )) as { CampaignID: string }[]; 51 | const ids = result.map((item: { CampaignID: string }) => item.CampaignID); 52 | setCampaignIds(ids); 53 | }; 54 | fetchData(); 55 | }, [month, audience, contentType, satisfaction, location, age, platform]); 56 | 57 | const handleCampaignChange = useCallback( 58 | (value: string | null) => { 59 | const params = new URLSearchParams(searchParams.toString()); 60 | if (value) { 61 | params.set("campaignId", value); 62 | } else { 63 | params.delete("campaignId"); 64 | } 65 | router.push(`/dashboard?${params.toString()}`, { scroll: false }); 66 | router.refresh(); 67 | setOpen(false); 68 | }, 69 | [router, searchParams] 70 | ); 71 | 72 | const filteredCampaignIds = campaignIds.filter((campaignId) => 73 | campaignId.toLowerCase().includes(searchQuery.toLowerCase()) 74 | ); 75 | 76 | if (!pathname?.startsWith("/dashboard")) { 77 | return null; 78 | } 79 | 80 | return ( 81 | 82 | 83 | 92 | 93 | 94 | 95 | 101 | No campaigns found. 102 | 103 | 104 | {filteredCampaignIds.map((campaignId) => ( 105 | handleCampaignChange(campaignId)} 109 | className="cursor-pointer flex items-center" 110 | > 111 | {campaignId} 112 | 120 | 121 | ))} 122 | 123 | 124 | 125 | 126 | 127 | ); 128 | } 129 | -------------------------------------------------------------------------------- /components/Charts.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { AI, QueryResult } from "@/app/action"; 3 | import { Card } from "@tremor/react"; 4 | 5 | import { ChartType } from "@/lib/validation"; 6 | import { 7 | AreaComp, 8 | BarComp, 9 | DonutComp, 10 | LineComp, 11 | NumberComp, 12 | ScatterComp, 13 | TableComp, 14 | } from "./llm-charts"; 15 | 16 | interface ChartProps { 17 | queryResult: QueryResult; 18 | chartType: ChartType; 19 | title?: string; 20 | description?: string; 21 | timeField?: string; 22 | categories: string[]; 23 | index?: string; 24 | yaxis?: string; 25 | size?: string; 26 | } 27 | 28 | export function Chart({ 29 | queryResult, 30 | chartType, 31 | title, 32 | timeField, 33 | categories, 34 | index, 35 | yaxis, 36 | size, 37 | }: ChartProps) { 38 | try { 39 | switch (chartType) { 40 | case "area": 41 | return ( 42 | 48 | ); 49 | case "number": 50 | return ; 51 | case "table": 52 | return ; 53 | case "line": 54 | return ( 55 | 61 | ); 62 | case "bar": 63 | return ( 64 | 70 | ); 71 | 72 | case "scatter": 73 | return ( 74 | 82 | ); 83 | case "donut": 84 | return ( 85 | 91 | ); 92 | 93 | default: 94 | throw new Error(`Unsupported chart type: ${chartType}`); 95 | } 96 | } catch (error) { 97 | console.error(error); 98 | return ( 99 |
100 | 101 |

102 | Error rendering chart 103 |

104 |
105 |
106 | ); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /components/Chat.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { AI } from "@/app/action"; 3 | import { ChatList } from "@/components/chat-list"; 4 | import { EmptyScreen } from "@/components/empty-screen"; 5 | import { UserMessage } from "@/components/message"; 6 | import { useEnterSubmit } from "@/lib/hooks/use-enter-submit"; 7 | import { useActions, useUIState } from "ai/rsc"; 8 | import { Send } from "lucide-react"; 9 | import React, { useEffect, useRef, useState } from "react"; 10 | import Textarea from "react-textarea-autosize"; 11 | import { Button } from "./ui/button"; 12 | import { 13 | Card, 14 | CardContent, 15 | CardDescription, 16 | CardFooter, 17 | CardHeader, 18 | CardTitle, 19 | } from "./ui/card"; 20 | 21 | export function Chat() { 22 | const [messages, setMessages] = useUIState(); 23 | const { submitUserMessage } = useActions(); 24 | const [inputValue, setInputValue] = useState(""); 25 | const { formRef, onKeyDown } = useEnterSubmit(); 26 | const inputRef = useRef(null); 27 | 28 | useEffect(() => { 29 | const handleKeyDown = (e: KeyboardEvent) => { 30 | if (e.key === "/") { 31 | if ( 32 | e.target && 33 | ["INPUT", "TEXTAREA"].includes((e.target as any).nodeName) 34 | ) { 35 | return; 36 | } 37 | e.preventDefault(); 38 | e.stopPropagation(); 39 | if (inputRef?.current) { 40 | inputRef.current.focus(); 41 | } 42 | } 43 | }; 44 | 45 | document.addEventListener("keydown", handleKeyDown); 46 | 47 | return () => { 48 | document.removeEventListener("keydown", handleKeyDown); 49 | }; 50 | }, [inputRef]); 51 | 52 | return ( 53 | 54 | 55 |
56 | Chat 57 | 65 |
66 | 67 | Chat with our AI assistant to get help on your queries 68 | 69 |
70 | 71 | {messages.length ? ( 72 | 73 | ) : ( 74 | { 76 | setMessages((currentMessages) => [ 77 | ...currentMessages, 78 | { 79 | id: Date.now(), 80 | display: {message}, 81 | }, 82 | ]); 83 | const responseMessage = await submitUserMessage(message); 84 | setMessages((currentMessages) => [ 85 | ...currentMessages, 86 | responseMessage, 87 | ]); 88 | }} 89 | /> 90 | )} 91 | 92 | 93 |
{ 96 | e.preventDefault(); 97 | const value = inputValue.trim(); 98 | setInputValue(""); 99 | if (!value) return; 100 | setMessages((currentMessages) => [ 101 | ...currentMessages, 102 | { 103 | id: Date.now(), 104 | display: {value}, 105 | }, 106 | ]); 107 | try { 108 | const responseMessage = await submitUserMessage(value); 109 | setMessages((currentMessages) => [ 110 | ...currentMessages, 111 | responseMessage, 112 | ]); 113 | } catch (error) { 114 | console.error(error); 115 | } 116 | }} 117 | className="flex gap-2 w-full" 118 | > 119 |