├── .github └── CODEOWNERS ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── LICENSE.txt ├── README.md ├── bun.lock ├── bunfig.toml ├── eslint.config.js ├── package.json ├── renovate.json ├── src ├── app.css ├── app.d.ts ├── app.html ├── hooks.client.ts ├── lib │ ├── account.svelte.ts │ ├── assignments.ts │ ├── components │ │ ├── DateBadge.svelte │ │ ├── Line.svelte │ │ ├── LoadingBanner.svelte │ │ ├── NumberInput.svelte │ │ └── RefreshIndicator.svelte │ ├── index.ts │ ├── synergy.ts │ └── types │ │ ├── Attachment.ts │ │ ├── Attendance.ts │ │ ├── AuthToken.ts │ │ ├── Documents.ts │ │ ├── Gradebook.ts │ │ ├── MailData.ts │ │ ├── ReportCard.ts │ │ └── StudentInfo.ts ├── routes │ ├── (authed) │ │ ├── +layout.svelte │ │ ├── AppSidebar.svelte │ │ ├── attendance │ │ │ ├── +page.svelte │ │ │ └── attendance.svelte.ts │ │ ├── documents │ │ │ ├── +page.svelte │ │ │ ├── document │ │ │ │ └── +page.svelte │ │ │ └── documents.svelte.ts │ │ ├── feedback │ │ │ └── +page.svelte │ │ ├── grades │ │ │ ├── +layout.svelte │ │ │ ├── +page.svelte │ │ │ ├── [index] │ │ │ │ ├── +page.server.ts │ │ │ │ ├── +page.svelte │ │ │ │ ├── AssignmentCard.svelte │ │ │ │ └── GradeChart.svelte │ │ │ └── gradebook.svelte.ts │ │ ├── mail │ │ │ ├── +page.svelte │ │ │ ├── MessageCard.svelte │ │ │ ├── MessageView.svelte │ │ │ ├── attachment │ │ │ │ └── +page.svelte │ │ │ └── mailData.svelte.ts │ │ └── studentinfo │ │ │ ├── +page.svelte │ │ │ └── studentInfo.svelte.ts │ ├── +error.svelte │ ├── +layout.server.ts │ ├── +layout.svelte │ ├── +page.svelte │ ├── login │ │ └── +page.svelte │ ├── privacy │ │ └── +page.svelte │ └── signup │ │ ├── +layout.svelte │ │ ├── +page.svelte │ │ ├── google │ │ └── +page.svelte │ │ └── password │ │ └── +page.svelte └── tests │ └── gradebook.test.ts ├── static ├── apple-touch-icon-180x180.png ├── favicon.ico ├── favicon.svg ├── manifest.json ├── maskable-icon-512x512.png ├── pwa-192x192.png ├── pwa-512x512.png ├── pwa-64x64.png └── robots.txt ├── svelte.config.js ├── tsconfig.json └── vite.config.ts /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @Nonexistent-Name 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | # Output 4 | .output 5 | .vercel 6 | .netlify 7 | .wrangler 8 | /.svelte-kit 9 | /build 10 | 11 | # OS 12 | .DS_Store 13 | Thumbs.db 14 | 15 | # Env 16 | .env 17 | .env.* 18 | !.env.example 19 | !.env.test 20 | 21 | # Vite 22 | vite.config.js.timestamp-* 23 | vite.config.ts.timestamp-* 24 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Package Managers 2 | bun.lock 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "printWidth": 100, 6 | "plugins": [ 7 | "prettier-plugin-svelte", 8 | "prettier-plugin-tailwindcss", 9 | "prettier-plugin-organize-imports" 10 | ], 11 | "overrides": [ 12 | { 13 | "files": "*.svelte", 14 | "options": { 15 | "parser": "svelte" 16 | } 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published 637 | by the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GradeVue 2 | 3 | An improved StudentVue experience. 4 | 5 | ## Developing 6 | 7 | Make sure to have [Bun](https://bun.sh) installed, then run `bun install` to install dependencies. 8 | To start a development server: 9 | 10 | ```bash 11 | bun run dev 12 | ``` 13 | 14 | ## Building 15 | 16 | To create a production version of your app: 17 | 18 | ```bash 19 | bun run build 20 | ``` 21 | 22 | You can preview the production build with `bun run preview`. 23 | 24 | Commits to this repository will automatically be deployed to [Vercel](https://gradevue.org). 25 | -------------------------------------------------------------------------------- /bunfig.toml: -------------------------------------------------------------------------------- 1 | [install] 2 | saveTextLockfile = true -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { includeIgnoreFile } from '@eslint/compat'; 2 | import js from '@eslint/js'; 3 | import prettier from 'eslint-config-prettier'; 4 | import svelte from 'eslint-plugin-svelte'; 5 | import globals from 'globals'; 6 | import { fileURLToPath } from 'node:url'; 7 | import ts from 'typescript-eslint'; 8 | const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url)); 9 | 10 | export default ts.config( 11 | includeIgnoreFile(gitignorePath), 12 | js.configs.recommended, 13 | ...ts.configs.recommended, 14 | ...svelte.configs['flat/recommended'], 15 | prettier, 16 | ...svelte.configs['flat/prettier'], 17 | { 18 | languageOptions: { 19 | globals: { 20 | ...globals.browser, 21 | ...globals.node 22 | } 23 | } 24 | }, 25 | { 26 | files: ['**/*.svelte'], 27 | 28 | languageOptions: { 29 | parserOptions: { 30 | parser: ts.parser 31 | } 32 | }, 33 | 34 | rules: { 35 | 'no-duplicate-imports': 'error', 36 | eqeqeq: 'error' 37 | } 38 | } 39 | ); 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gradevue", 3 | "private": true, 4 | "version": "0.0.1", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite dev", 8 | "build": "vite build", 9 | "preview": "vite preview", 10 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 11 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 12 | "format": "prettier --write .", 13 | "lint": "prettier --check . && eslint ." 14 | }, 15 | "devDependencies": { 16 | "@eslint/compat": "^1.2.7", 17 | "@number-flow/svelte": "^0.3.3", 18 | "@sveltejs/adapter-auto": "^4.0.0", 19 | "@sveltejs/adapter-vercel": "^5.6.3", 20 | "@sveltejs/kit": "^2.17.2", 21 | "@sveltejs/vite-plugin-svelte": "^5.0.3", 22 | "@tailwindcss/vite": "^4.0.8", 23 | "@types/bun": "^1.2.3", 24 | "@vite-pwa/assets-generator": "^0.2.6", 25 | "eslint": "^9.21.0", 26 | "eslint-config-prettier": "^10.0.1", 27 | "eslint-plugin-svelte": "^2.46.1", 28 | "flowbite": "3.0.0", 29 | "flowbite-svelte": "^0.48.3", 30 | "flowbite-svelte-icons": "^2.0.2", 31 | "globals": "^16.0.0", 32 | "prettier": "^3.5.2", 33 | "prettier-plugin-organize-imports": "^4.1.0", 34 | "prettier-plugin-svelte": "^3.3.3", 35 | "prettier-plugin-tailwindcss": "^0.6.11", 36 | "svelte": "^5.20.2", 37 | "svelte-check": "^4.1.4", 38 | "tailwindcss": "^4.0.8", 39 | "typescript": "^5.7.3", 40 | "typescript-eslint": "^8.24.1", 41 | "vite": "^6.1.1" 42 | }, 43 | "dependencies": { 44 | "buffer": "^6.0.3", 45 | "chart.js": "^4.4.8", 46 | "chartjs-adapter-date-fns": "^3.0.0", 47 | "chartjs-plugin-gradient": "^0.6.1", 48 | "chartjs-scale-timestack": "^1.0.1", 49 | "fast-xml-parser": "^5.0.6", 50 | "file-type": "^20.1.0" 51 | }, 52 | "engines": { 53 | "node": "22.x" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:recommended"] 4 | } 5 | -------------------------------------------------------------------------------- /src/app.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | 3 | @plugin 'flowbite/plugin'; 4 | 5 | @source '../node_modules/flowbite-svelte/**/*.{html,js,svelte,ts}'; 6 | 7 | @custom-variant dark (&:is(.dark *)); 8 | 9 | @theme { 10 | --color-primary-50: #fff5f2; 11 | --color-primary-100: #fff1ee; 12 | --color-primary-200: #ffe4de; 13 | --color-primary-300: #ffd5cc; 14 | --color-primary-400: #ffbcad; 15 | --color-primary-500: #fe795d; 16 | --color-primary-600: #ef562f; 17 | --color-primary-700: #eb4f27; 18 | --color-primary-800: #cc4522; 19 | --color-primary-900: #a5371b; 20 | } 21 | 22 | /* 23 | The default border color has changed to `currentColor` in Tailwind CSS v4, 24 | so we've added these compatibility styles to make sure everything still 25 | looks the same as it did with Tailwind CSS v3. 26 | 27 | If we ever want to remove these styles, we need to add an explicit border 28 | color utility to any element that depends on these defaults. 29 | */ 30 | @layer base { 31 | *, 32 | ::after, 33 | ::before, 34 | ::backdrop, 35 | ::file-selector-button { 36 | border-color: var(--color-gray-200, currentColor); 37 | } 38 | } 39 | 40 | html { 41 | font-family: Inter, sans-serif; 42 | font-size: 1rem; 43 | line-height: 1.5; 44 | } 45 | -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://kit.svelte.dev/docs/types#app 2 | // for information about these interfaces 3 | declare global { 4 | namespace App { 5 | // interface Error {} 6 | // interface Locals {} 7 | // interface PageData {} 8 | // interface PageState {} 9 | // interface Platform {} 10 | } 11 | } 12 | 13 | export {}; 14 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 14 | 15 | 20 | %sveltekit.head% 21 | 22 | 23 |
%sveltekit.body%
24 | 25 | 26 | -------------------------------------------------------------------------------- /src/hooks.client.ts: -------------------------------------------------------------------------------- 1 | import type { ClientInit } from '@sveltejs/kit'; 2 | import { writable, type Writable } from 'svelte/store'; 3 | 4 | export const installPrompt: Writable<{ prompt?: () => Promise }> = writable({}); 5 | 6 | interface UserChoice { 7 | outcome: 'accepted' | 'dismissed'; 8 | platform: string; 9 | } 10 | 11 | interface BeforeInstallPromptEvent extends Event { 12 | platforms: string[]; 13 | userChoice: Promise; 14 | prompt: () => Promise; 15 | } 16 | 17 | export const init: ClientInit = async () => { 18 | addEventListener('beforeinstallprompt', (e) => { 19 | const event = e as BeforeInstallPromptEvent; 20 | 21 | event.preventDefault(); 22 | installPrompt.set({ prompt: event.prompt.bind(event) }); 23 | }); 24 | }; 25 | -------------------------------------------------------------------------------- /src/lib/account.svelte.ts: -------------------------------------------------------------------------------- 1 | import { LocalStorageKey } from '$lib'; 2 | import { StudentAccount } from '$lib/synergy'; 3 | 4 | export const acc: { studentAccount?: StudentAccount } = $state({}); 5 | 6 | export const loadStudentAccount = () => { 7 | const token = localStorage.getItem(LocalStorageKey.token); 8 | 9 | if (!token) return; 10 | 11 | const { username, password, domain } = JSON.parse(token); 12 | 13 | acc.studentAccount = new StudentAccount(domain, username, password); 14 | }; 15 | -------------------------------------------------------------------------------- /src/lib/assignments.ts: -------------------------------------------------------------------------------- 1 | import type { AssignmentEntity, Course } from './types/Gradebook'; 2 | 3 | export interface Category { 4 | name: string; 5 | weightPercentage: number; 6 | pointsEarned: number; 7 | pointsPossible: number; 8 | weightedPercentage: number; 9 | gradeLetter: string; 10 | } 11 | 12 | export interface Assignment { 13 | name: string; 14 | id: string | undefined; 15 | pointsEarned: number | undefined; 16 | pointsPossible: number | undefined; 17 | unscaledPoints: { pointsEarned: number; pointsPossible: number } | undefined; 18 | extraCredit: boolean; 19 | gradePercentageChange: number | undefined; 20 | notForGrade: boolean; 21 | hidden: boolean; 22 | category: string | undefined; 23 | date: Date; 24 | newHypothetical: boolean; 25 | } 26 | 27 | export interface RealAssignment extends Assignment { 28 | id: string; 29 | hidden: false; 30 | category: string; 31 | newHypothetical: false; 32 | } 33 | 34 | export interface HiddenAssignment extends Assignment { 35 | id: undefined; 36 | pointsEarned: number; 37 | pointsPossible: number; 38 | unscaledPoints: undefined; 39 | extraCredit: false; 40 | notForGrade: false; 41 | hidden: true; 42 | category: string; 43 | newHypothetical: false; 44 | } 45 | 46 | export interface ReactiveAssignment extends Assignment { 47 | reactive: true; 48 | } 49 | 50 | export interface NewHypotheticalAssignment extends ReactiveAssignment { 51 | id: undefined; 52 | newHypothetical: true; 53 | unscaledPoints: undefined; 54 | extraCredit: false; 55 | hidden: false; 56 | } 57 | 58 | export type Calculable = T & { 59 | pointsEarned: number; 60 | pointsPossible: number; 61 | notForGrade: false; 62 | category: string; 63 | }; 64 | 65 | export type Flowed = Calculable & { 66 | gradePercentageChange: number; 67 | }; 68 | 69 | interface PointsByCategory { 70 | [categoryName: string]: { 71 | pointsEarned: number; 72 | pointsPossible: number; 73 | }; 74 | } 75 | 76 | export function calculateGradePercentage(pointsEarned: number, pointsPossible: number) { 77 | let gradePercentage = (pointsEarned / pointsPossible) * 100; 78 | 79 | if (isNaN(gradePercentage)) gradePercentage = 0; 80 | 81 | return gradePercentage; 82 | } 83 | 84 | export function calculateCourseGradePercentageFromCategories( 85 | pointsByCategory: PointsByCategory, 86 | gradeCategories: Category[] 87 | ) { 88 | let gradePercentage = 0; 89 | if (Object.entries(pointsByCategory).length === 0) return 0; 90 | 91 | let totalWeight = 0; 92 | 93 | Object.entries(pointsByCategory).forEach(([categoryName, categoryPoints]) => { 94 | const category = gradeCategories.find((category) => category.name === categoryName); 95 | if (!category) return; 96 | 97 | gradePercentage += 98 | (categoryPoints.pointsEarned / categoryPoints.pointsPossible) * category.weightPercentage; 99 | totalWeight += category.weightPercentage; 100 | }); 101 | 102 | gradePercentage = (gradePercentage / totalWeight) * 100; 103 | 104 | if (isNaN(gradePercentage)) { 105 | console.error('Grade percentage is NaN'); 106 | return 0; 107 | } 108 | 109 | return gradePercentage; 110 | } 111 | 112 | export function calculateCourseGradePercentageFromTotals( 113 | assignments: Calculable[] 114 | ) { 115 | const { pointsEarned, pointsPossible } = getAssignmentPointTotals(assignments); 116 | 117 | return calculateGradePercentage(pointsEarned, pointsPossible); 118 | } 119 | 120 | function flowAssignmentFromTotals( 121 | assignment: Calculable, 122 | totalPointsEarned: number, 123 | totalPointsPossible: number 124 | ): { assignment: Flowed; totalPointsEarned: number; totalPointsPossible: number } { 125 | const { pointsEarned, pointsPossible, extraCredit } = assignment; 126 | 127 | const priorGrade = calculateGradePercentage(totalPointsEarned, totalPointsPossible); 128 | 129 | totalPointsEarned += pointsEarned; 130 | totalPointsPossible += extraCredit ? 0 : pointsPossible; 131 | 132 | const afterGrade = calculateGradePercentage(totalPointsEarned, totalPointsPossible); 133 | 134 | const gradePercentageChange = afterGrade - priorGrade; 135 | 136 | const calculable = { ...assignment, gradePercentageChange }; 137 | 138 | return { assignment: calculable, totalPointsEarned, totalPointsPossible }; 139 | } 140 | 141 | function flowAssignmentFromCategories( 142 | assignment: Calculable, 143 | pointsByCategory: PointsByCategory, 144 | gradeCategories: Category[] 145 | ): { assignment: Flowed; pointsByCategory: PointsByCategory } { 146 | const { pointsEarned, pointsPossible, category, extraCredit } = assignment; 147 | 148 | const priorGrade = calculateCourseGradePercentageFromCategories( 149 | pointsByCategory, 150 | gradeCategories 151 | ); 152 | 153 | const categoryPoints = pointsByCategory[category] ?? { pointsEarned: 0, pointsPossible: 0 }; 154 | pointsByCategory[category] = { 155 | pointsEarned: categoryPoints.pointsEarned + pointsEarned, 156 | pointsPossible: categoryPoints.pointsPossible + (extraCredit ? 0 : pointsPossible) 157 | }; 158 | 159 | const afterGrade = calculateCourseGradePercentageFromCategories( 160 | pointsByCategory, 161 | gradeCategories 162 | ); 163 | 164 | const gradePercentageChange = afterGrade - priorGrade; 165 | 166 | const calculable = { ...assignment, gradePercentageChange }; 167 | 168 | return { assignment: calculable, pointsByCategory }; 169 | } 170 | 171 | export function calculateAssignmentGPCsFromCategories( 172 | assignments: T[], 173 | gradeCategories: Category[] 174 | ) { 175 | let pointsByCategory: PointsByCategory = {}; 176 | 177 | const flowedAssignments: (T | Flowed)[] = assignments 178 | .toReversed() 179 | .map((assignment) => { 180 | const { pointsEarned, pointsPossible, notForGrade, category } = assignment; 181 | 182 | if ( 183 | pointsEarned === undefined || 184 | pointsPossible === undefined || 185 | notForGrade || 186 | category === undefined 187 | ) 188 | return assignment; 189 | 190 | const calculable: Calculable = { 191 | ...assignment, 192 | pointsEarned, 193 | pointsPossible, 194 | notForGrade, 195 | category 196 | }; 197 | 198 | const flowed = flowAssignmentFromCategories(calculable, pointsByCategory, gradeCategories); 199 | 200 | pointsByCategory = flowed.pointsByCategory; 201 | 202 | return flowed.assignment; 203 | }) 204 | .toReversed(); 205 | 206 | return flowedAssignments; 207 | } 208 | 209 | export function calculateAssignmentGPCsFromTotals(assignments: T[]) { 210 | let totalPointsEarned = 0; 211 | let totalPointsPossible = 0; 212 | 213 | const flowedAssignments: (T | Flowed)[] = assignments 214 | .toReversed() 215 | .map((assignment) => { 216 | const { pointsEarned, pointsPossible, notForGrade, category } = assignment; 217 | 218 | if ( 219 | pointsEarned === undefined || 220 | pointsPossible === undefined || 221 | notForGrade || 222 | category === undefined 223 | ) 224 | return assignment; 225 | 226 | const calculable: Calculable = { 227 | ...assignment, 228 | pointsEarned, 229 | pointsPossible, 230 | notForGrade, 231 | category 232 | }; 233 | 234 | const flowed = flowAssignmentFromTotals(calculable, totalPointsEarned, totalPointsPossible); 235 | totalPointsEarned = flowed.totalPointsEarned; 236 | totalPointsPossible = flowed.totalPointsPossible; 237 | return flowed.assignment; 238 | }) 239 | .toReversed(); 240 | 241 | return flowedAssignments; 242 | } 243 | 244 | export function calculateAssignmentGPCs( 245 | assignments: T[], 246 | gradeCategories?: Category[] 247 | ) { 248 | if (gradeCategories === undefined) { 249 | return calculateAssignmentGPCsFromTotals(assignments); 250 | } 251 | 252 | return calculateAssignmentGPCsFromCategories(assignments, gradeCategories); 253 | } 254 | 255 | export function getHiddenAssignmentsFromCategories( 256 | categories: Category[], 257 | pointsByCategory: PointsByCategory 258 | ) { 259 | return categories 260 | .filter( 261 | (category) => 262 | pointsByCategory[category.name] && 263 | (category.pointsEarned !== pointsByCategory[category.name].pointsEarned || 264 | category.pointsPossible !== pointsByCategory[category.name].pointsPossible) 265 | ) 266 | .map((category) => { 267 | const { pointsEarned, pointsPossible } = pointsByCategory[category.name]; 268 | 269 | const hiddenPointsEarned = category.pointsEarned - pointsEarned; 270 | const hiddenPointsPossible = category.pointsPossible - pointsPossible; 271 | 272 | // Calculate grade prior to the assignment 273 | const priorGrade = calculateCourseGradePercentageFromCategories(pointsByCategory, categories); 274 | 275 | pointsByCategory[category.name] = { 276 | pointsEarned: pointsEarned + hiddenPointsEarned, 277 | pointsPossible: pointsPossible + hiddenPointsPossible 278 | }; 279 | 280 | // Calculate grade after the assignment 281 | const afterGrade = calculateCourseGradePercentageFromCategories(pointsByCategory, categories); 282 | 283 | // Calculate grade percentage change and initalize hypothetical gradebook 284 | const gradePercentageChange = afterGrade - priorGrade; 285 | 286 | if (Math.abs(gradePercentageChange) < 0.0001) return null; 287 | 288 | const hiddenAssignment: Flowed = { 289 | name: `Hidden ${category.name} Assignments`, 290 | id: undefined, 291 | pointsEarned: hiddenPointsEarned, 292 | pointsPossible: hiddenPointsPossible, 293 | unscaledPoints: undefined, 294 | extraCredit: false, 295 | gradePercentageChange, 296 | notForGrade: false, 297 | hidden: true, 298 | category: category.name, 299 | date: new Date(), 300 | newHypothetical: false 301 | }; 302 | 303 | return hiddenAssignment; 304 | }) 305 | .filter((x) => x !== null); 306 | } 307 | 308 | export function getPointsByCategory(assignments: Calculable[]) { 309 | const pointsByCategory: PointsByCategory = {}; 310 | 311 | assignments.forEach((assignment) => { 312 | const { category, pointsEarned, pointsPossible, extraCredit } = assignment; 313 | 314 | const categoryPoints = pointsByCategory[category] ?? { pointsEarned: 0, pointsPossible: 0 }; 315 | 316 | pointsByCategory[category] = { 317 | pointsEarned: categoryPoints.pointsEarned + pointsEarned, 318 | pointsPossible: categoryPoints.pointsPossible + (extraCredit ? 0 : pointsPossible) 319 | }; 320 | }); 321 | 322 | return pointsByCategory; 323 | } 324 | 325 | function countDecimalPlaces(num: number) { 326 | const numStr = num.toString(); 327 | const decimalIndex = numStr.indexOf('.'); 328 | 329 | if (decimalIndex === -1) return 0; 330 | 331 | return numStr.length - decimalIndex - 1; 332 | } 333 | 334 | function roundToPrecision(num: number, precision: number) { 335 | const factor = Math.pow(10, precision); 336 | return Math.round(num * factor) / factor; 337 | } 338 | 339 | function floorToPrecision(num: number, precision: number) { 340 | const factor = Math.pow(10, precision); 341 | return Math.floor(num * factor) / factor; 342 | } 343 | 344 | export function gradesMatch(rawGrade: number, expectedGrade: number) { 345 | const leastPrecision = Math.min(countDecimalPlaces(rawGrade), countDecimalPlaces(expectedGrade)); 346 | 347 | const roundedMatches = 348 | roundToPrecision(rawGrade, leastPrecision) === roundToPrecision(expectedGrade, leastPrecision); 349 | 350 | const flooredMatches = 351 | floorToPrecision(rawGrade, leastPrecision) === floorToPrecision(expectedGrade, leastPrecision); 352 | 353 | return roundedMatches || flooredMatches; 354 | } 355 | 356 | function getAssignmentPointTotals(assignments: Calculable[]) { 357 | let pointsEarned = 0; 358 | let pointsPossible = 0; 359 | 360 | assignments.forEach((assignment) => { 361 | const { pointsEarned: earned, pointsPossible: possible, extraCredit } = assignment; 362 | 363 | pointsEarned += earned; 364 | if (!extraCredit) pointsPossible += possible; 365 | }); 366 | 367 | return { pointsEarned, pointsPossible }; 368 | } 369 | 370 | export function getSynergyCourseAssignmentCategories(course: Course) { 371 | const gradeCalcSummary = course?.Marks.Mark.GradeCalculationSummary; 372 | 373 | if (typeof gradeCalcSummary === 'string' || !gradeCalcSummary?.AssignmentGradeCalc) 374 | return undefined; 375 | 376 | const categories: Category[] = gradeCalcSummary.AssignmentGradeCalc.map((category) => ({ 377 | name: category._Type, 378 | weightPercentage: parseFloat(category._Weight), 379 | pointsEarned: parseFloat(category._Points), 380 | pointsPossible: parseFloat(category._PointsPossible), 381 | weightedPercentage: parseFloat(category._WeightedPct), 382 | gradeLetter: category._CalculatedMark 383 | })); 384 | 385 | return categories; 386 | } 387 | 388 | export function getCalculableAssignments(assignments: T[]) { 389 | return assignments 390 | .map((assignment) => { 391 | const { pointsEarned, pointsPossible, notForGrade, category } = assignment; 392 | 393 | if ( 394 | pointsEarned === undefined || 395 | pointsPossible === undefined || 396 | notForGrade || 397 | category === undefined 398 | ) 399 | return null; 400 | 401 | const calculable: Calculable = { 402 | ...assignment, 403 | pointsEarned, 404 | pointsPossible, 405 | notForGrade, 406 | category 407 | }; 408 | 409 | return calculable; 410 | }) 411 | .filter((assignments) => assignments !== null); 412 | } 413 | 414 | export function parseSynergyAssignment(synergyAssignment: AssignmentEntity) { 415 | const { 416 | _Date, 417 | _Measure, 418 | _Notes, 419 | _Point, 420 | _PointPossible, 421 | _Points, 422 | _ScoreCalValue, 423 | _ScoreMaxValue, 424 | _Type 425 | } = synergyAssignment; 426 | 427 | // Edge Cases: 428 | 429 | // Normal: 430 | // _Point: "3" 431 | // _PointPossible: "4" 432 | // _Points: "3 / 4" 433 | // _ScoreCalValue: "3" 434 | // _ScoreMaxValue: "4" 435 | // _DisplayScore: "3 out of 4" 436 | 437 | // Not Graded: 438 | // _Point: undefined 439 | // _PointPossible: undefined 440 | // _Points: "4 Points Possible" 441 | // _ScoreCalValue: undefined 442 | // _ScoreMaxValue: "4" or undefined 443 | // _DisplayScore: "Not Graded" 444 | 445 | // Not Graded (Empty): 446 | // _Point: undefined 447 | // _PointPossible: undefined 448 | // _Points: "Points Possible" 449 | // _ScoreCalValue: undefined 450 | // _ScoreMaxValue: undefined 451 | // _DisplayScore: "Not Graded" 452 | 453 | // Zero (Blank _Point): 454 | // _Point: "" 455 | // _PointPossible: "4" 456 | // _Points: "/ 4" 457 | // _ScoreCalValue: "0" 458 | // _ScoreMaxValue: "4" 459 | // _DisplayScore: "0 out of 4" 460 | 461 | // Extra Credit: 462 | // _Point: "3" 463 | // _PointPossible: "" 464 | // _Points: "3 /" 465 | // _ScoreCalValue: "3" 466 | // _ScoreMaxValue: "4" 467 | // _DisplayScore: "3 out of 4" 468 | 469 | // Not For Grading: 470 | // _Point: "3" 471 | // _PointPossible: "4" 472 | // _Points: "3 / 4" 473 | // _ScoreCalValue: "3" 474 | // _ScoreMaxValue: "4" 475 | // _DisplayScore: "3 out of 4" 476 | // _Notes : "(Not For Grading)" 477 | 478 | // Scaled: 479 | // _Point: "6" 480 | // _PointPossible: "8" 481 | // _Points: "6 / 8" 482 | // _ScoreCalValue: "3" 483 | // _ScoreMaxValue: "4" 484 | // _DisplayScore: "3 out of 4" 485 | 486 | const pointsEarned = _Point !== undefined ? (_Point === '' ? 0 : parseFloat(_Point)) : undefined; // _Point can be empty; equivalent to 0 487 | 488 | const pointsPossible = 489 | _PointPossible !== undefined && _PointPossible !== '' 490 | ? parseFloat(_PointPossible) 491 | : _ScoreMaxValue !== undefined 492 | ? parseFloat(_ScoreMaxValue) 493 | : _Points === 'Points Possible' 494 | ? undefined 495 | : parseFloat(_Points.split(' Points Possible')[0]); 496 | 497 | const pointsEarnedIsScaled = 498 | _Point !== undefined && 499 | _Point !== '' && 500 | _ScoreCalValue !== undefined && 501 | _Point !== _ScoreCalValue; 502 | 503 | const pointsPossibleIsScaled = 504 | _PointPossible !== undefined && 505 | _PointPossible !== '' && 506 | _ScoreMaxValue !== undefined && 507 | _PointPossible !== _ScoreMaxValue; 508 | 509 | let unscaledPoints: { pointsEarned: number; pointsPossible: number } | undefined = undefined; 510 | 511 | if ((pointsEarnedIsScaled || pointsPossibleIsScaled) && _ScoreCalValue && _ScoreMaxValue) { 512 | unscaledPoints = { 513 | pointsEarned: parseFloat(_ScoreCalValue), 514 | pointsPossible: parseFloat(_ScoreMaxValue) 515 | }; 516 | } 517 | 518 | const assignment: RealAssignment = { 519 | name: _Measure, 520 | id: synergyAssignment._GradebookID, 521 | pointsEarned, 522 | pointsPossible, 523 | unscaledPoints, 524 | extraCredit: _PointPossible === '', 525 | gradePercentageChange: undefined, 526 | notForGrade: _Notes.includes('(Not For Grading)'), 527 | hidden: false, 528 | category: _Type, 529 | date: new Date(_Date), 530 | newHypothetical: false 531 | }; 532 | 533 | return assignment; 534 | } 535 | -------------------------------------------------------------------------------- /src/lib/components/DateBadge.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | {shortDateFormatter.format(date)} 13 | 14 | {fullDateFormatter.format(date)} 15 | ({getRelativeTime(date)}) 16 | 17 | -------------------------------------------------------------------------------- /src/lib/components/Line.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /src/lib/components/LoadingBanner.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | {#if show} 14 |
15 |
16 | 17 | {loadingMsg} 18 |
19 |
20 | {/if} 21 | -------------------------------------------------------------------------------- /src/lib/components/NumberInput.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 44 | 45 | 46 | { 63 | // This hack is necessary to make sure that value is updated before the event fires. $effect isn't fast enough. 64 | value = nullableValue ?? undefined; 65 | oninput?.(e); 66 | }} 67 | /> 68 | 69 | -------------------------------------------------------------------------------- /src/lib/components/RefreshIndicator.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 |
17 | 18 | Last updated {getRelativeTime(new Date(lastRefresh))} 19 | 22 |
23 | -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | import { Buffer } from 'buffer'; 2 | import { fileTypeFromBuffer } from 'file-type'; 3 | import { acc } from './account.svelte'; 4 | 5 | export function getColorForGrade(grade: string | number) { 6 | if (typeof grade == 'number') { 7 | if (grade > 100) return 'blue'; 8 | if (grade >= 90) return 'green'; 9 | else if (grade >= 80) return 'yellow'; 10 | else return 'red'; 11 | } 12 | 13 | if (grade.match(/^A\+?-?$/)) return 'green'; 14 | else if (grade.match(/^B\+?-?$/)) return 'yellow'; 15 | else if (grade.match(/^[CDEF]\+?-?$/)) return 'red'; 16 | return 'gray'; 17 | } 18 | 19 | export const removeClassID = (name: string) => name.replace(/ \([A-Z]+\)( \([0-9]+\))?$/, ''); 20 | 21 | const rtf = new Intl.RelativeTimeFormat('en-US', { numeric: 'auto' }); 22 | 23 | export function getRelativeTime(date: Date) { 24 | const now = new Date(); 25 | const seconds = Math.floor((now.getTime() - date.getTime()) / 1000); 26 | const minutes = Math.floor(seconds / 60); 27 | const hours = Math.floor(minutes / 60); 28 | const days = Math.floor(hours / 24); 29 | const months = Math.floor(days / 30); 30 | const years = Math.floor(days / 365); 31 | 32 | if (seconds < 60) { 33 | return rtf.format(-seconds, 'second'); 34 | } else if (minutes < 60) { 35 | return rtf.format(-minutes, 'minute'); 36 | } else if (hours < 24) { 37 | return rtf.format(-hours, 'hour'); 38 | } else if (days < 30) { 39 | return rtf.format(-days, 'day'); 40 | } else if (months < 12) { 41 | return rtf.format(-months, 'month'); 42 | } else { 43 | return rtf.format(-years, 'year'); 44 | } 45 | } 46 | 47 | export const shortDateFormatter = new Intl.DateTimeFormat('en-US', { 48 | dateStyle: 'short' 49 | }); 50 | 51 | export const fullDateFormatter = new Intl.DateTimeFormat('en-US', { 52 | dateStyle: 'full' 53 | }); 54 | 55 | export async function getBlobURLFromBase64String(base64: string) { 56 | const byteArray = new Uint8Array(Buffer.from(base64, 'base64')); 57 | 58 | const mimeType = (await fileTypeFromBuffer(byteArray))?.mime; 59 | 60 | if (!mimeType) throw new Error('Could not determine MIME type'); 61 | 62 | const blob = new Blob([byteArray], { type: mimeType }); 63 | 64 | return URL.createObjectURL(blob); 65 | } 66 | 67 | export enum LocalStorageKey { 68 | token = 'token', 69 | gradebook = 'gradebook2', 70 | seenAssignmentIDs = 'seenAssignmentIDs', 71 | attendance = 'attendance', 72 | documents = 'documents', 73 | mailData = 'mailData', 74 | studentInfo = 'studentInfo' 75 | } 76 | 77 | export interface RecordState { 78 | data?: T; 79 | loaded: boolean; 80 | lastRefresh?: number; 81 | } 82 | 83 | export interface LocalStorageCache { 84 | data: T; 85 | lastRefresh: number; 86 | } 87 | 88 | export const loadRecord = async ( 89 | recordState: RecordState, 90 | loadFunc: () => Promise, 91 | localStorageKey: string, 92 | cacheExpirationTime: number | undefined, 93 | forceRefresh = false 94 | ) => { 95 | if ((recordState.data && !forceRefresh) || !acc.studentAccount) return; 96 | 97 | recordState.loaded = false; 98 | 99 | let refresh = true; 100 | 101 | const cacheStr = localStorage.getItem(localStorageKey); 102 | if (cacheStr) { 103 | try { 104 | const cache: LocalStorageCache = JSON.parse(cacheStr); 105 | 106 | recordState.data = cache.data; 107 | recordState.lastRefresh = cache.lastRefresh; 108 | 109 | if (cacheExpirationTime !== undefined && Date.now() - cache.lastRefresh < cacheExpirationTime) 110 | refresh = false; 111 | } catch (e) { 112 | console.error(e); 113 | localStorage.removeItem(localStorageKey); 114 | } 115 | } 116 | 117 | if (refresh || forceRefresh) { 118 | try { 119 | recordState.data = await loadFunc(); 120 | recordState.lastRefresh = Date.now(); 121 | 122 | const newCache: LocalStorageCache = { 123 | data: recordState.data, 124 | lastRefresh: recordState.lastRefresh 125 | }; 126 | 127 | localStorage.setItem(localStorageKey, JSON.stringify(newCache)); 128 | } catch (err) { 129 | console.error(err); 130 | } 131 | } 132 | 133 | recordState.loaded = true; 134 | }; 135 | -------------------------------------------------------------------------------- /src/lib/synergy.ts: -------------------------------------------------------------------------------- 1 | import type { Attachment } from '$lib/types/Attachment'; 2 | import type { Attendance } from '$lib/types/Attendance'; 3 | import type { AuthToken } from '$lib/types/AuthToken'; 4 | import type { Documents } from '$lib/types/Documents'; 5 | import type { Gradebook } from '$lib/types/Gradebook'; 6 | import type { MailData } from '$lib/types/MailData'; 7 | import type { ReportCard } from '$lib/types/ReportCard'; 8 | import type { StudentInfo } from '$lib/types/StudentInfo'; 9 | import { XMLBuilder, XMLParser } from 'fast-xml-parser'; 10 | 11 | const alwaysArray = [ 12 | 'Gradebook.Courses.Course', 13 | 'Gradebook.Courses.Course.Marks.Mark.Assignments.Assignment', 14 | 'Gradebook.ReportingPeriods.ReportPeriod' 15 | ]; 16 | 17 | const parser = new XMLParser({ 18 | ignoreAttributes: false, 19 | ignoreDeclaration: true, 20 | attributeNamePrefix: '_', 21 | isArray: (_name, jpath) => alwaysArray.includes(jpath) 22 | }); 23 | 24 | const builder = new XMLBuilder({ 25 | ignoreAttributes: false, 26 | attributeNamePrefix: '_' 27 | }); 28 | 29 | export class StudentAccount { 30 | domain: string; 31 | userID: string; 32 | password: string; 33 | 34 | constructor(domain: string, userID: string, password: string) { 35 | this.domain = domain; 36 | this.userID = userID; 37 | this.password = password; 38 | } 39 | 40 | async soapRequest(operation: string, methodName: string, params: unknown = {}) { 41 | const paramStr = builder 42 | .build({ Params: params }) 43 | .replaceAll('<', '<') 44 | .replaceAll('>', '>'); 45 | 46 | const res = await fetch(`https://${this.domain}/Service/PXPCommunication.asmx?WSDL`, { 47 | method: 'POST', 48 | headers: { 'Content-Type': 'application/soap+xml; charset=utf-8' }, 49 | body: ` 50 | 51 | 52 | <${operation} xmlns="http://edupoint.com/webservices/"> 53 | ${this.userID} 54 | ${this.password} 55 | true 56 | false 57 | PXPWebServices 58 | ${methodName} 59 | ${paramStr} 60 | 61 | 62 | ` 63 | }); 64 | 65 | const result = parser.parse( 66 | parser.parse(await res.text())['soap:Envelope']['soap:Body'][operation + 'Response'][ 67 | operation + 'Result' 68 | ] 69 | ); 70 | 71 | if (result.RT_ERROR) throw new Error(result.RT_ERROR._ERROR_MESSAGE); 72 | 73 | return result; 74 | } 75 | 76 | async request(methodName: string, params: unknown = {}) { 77 | return this.soapRequest('ProcessWebServiceRequest', methodName, params); 78 | } 79 | 80 | async requestMultiWeb(methodName: string, params: unknown = {}) { 81 | return this.soapRequest('ProcessWebServiceRequestMultiWeb', methodName, params); 82 | } 83 | 84 | async checkLogin() { 85 | await this.request('StudentInfo'); 86 | } 87 | 88 | async getAuthToken(): Promise { 89 | return ( 90 | await this.requestMultiWeb('GenerateAuthToken', { 91 | Username: this.userID, 92 | TokenForClassWebSite: true, 93 | Usertype: 0, 94 | IsParentStudent: 0, 95 | DataString: '', 96 | DocumentID: 1, 97 | AssignmentID: 1 98 | }) 99 | ).AuthToken; 100 | } 101 | 102 | async gradebook(reportPeriod?: number): Promise { 103 | if (reportPeriod) { 104 | // May return current reporting period instead of the one requested if the one requested cannot be found 105 | 106 | return (await this.request('Gradebook', { ReportPeriod: reportPeriod })).Gradebook; 107 | } 108 | 109 | return (await this.request('Gradebook')).Gradebook; 110 | } 111 | 112 | async attendance(): Promise { 113 | return (await this.request('Attendance')).Attendance; 114 | } 115 | 116 | async studentInfo(): Promise { 117 | return (await this.request('StudentInfo')).StudentInfo; 118 | } 119 | 120 | async documents(): Promise { 121 | return (await this.request('GetStudentDocumentInitialData')).StudentDocuments; 122 | } 123 | 124 | async reportCard(documentGU: string): Promise { 125 | return (await this.request('GetReportCardDocumentData', { DocumentGU: documentGU })) 126 | .DocumentData; 127 | } 128 | 129 | async mailData(): Promise { 130 | return (await this.request('SynergyMailGetData')).SynergyMailDataXML; 131 | } 132 | 133 | async attachment(attachmentGU: string): Promise { 134 | return (await this.request('SynergyMailGetAttachment', { SmAttachmentGU: attachmentGU })) 135 | .AttachmentXML; 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/lib/types/Attachment.ts: -------------------------------------------------------------------------------- 1 | export interface Attachment { 2 | Base64Code: string; 3 | '_xmlns:xsd': string; 4 | '_xmlns:xsi': string; 5 | _SmAttachmentGU: string; 6 | _DocumentName: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/lib/types/Attendance.ts: -------------------------------------------------------------------------------- 1 | export interface Attendance { 2 | Absences: Absences; 3 | TotalExcused: AttendanceEventTotal; 4 | TotalTardies: AttendanceEventTotal; 5 | TotalUnexcused: AttendanceEventTotal; 6 | TotalActivities: AttendanceEventTotal; 7 | TotalUnexcusedTardies: AttendanceEventTotal; 8 | ConcurrentSchoolsLists: string; 9 | '_xmlns:xsd': string; 10 | '_xmlns:xsi': string; 11 | _Type: string; 12 | _StartPeriod: string; 13 | _EndPeriod: string; 14 | _PeriodCount: string; 15 | _SchoolName: string; 16 | } 17 | 18 | export interface Absences { 19 | Absence?: Absence[] | null; 20 | } 21 | 22 | export interface Absence { 23 | Periods: Periods; 24 | _AbsenceDate: string; 25 | _Reason: string; 26 | _Note: string; 27 | _DailyIconName: string; 28 | _CodeAllDayReasonType: string; 29 | _CodeAllDayDescription: string; 30 | } 31 | 32 | export interface Periods { 33 | Period?: Period[] | null; 34 | } 35 | 36 | export interface Period { 37 | _Number: string; 38 | _Name: string; 39 | _Reason: string; 40 | _Course: string; 41 | _Staff: string; 42 | _StaffEMail: string; 43 | _IconName: string; 44 | _SchoolName: string; 45 | _StaffGU: string; 46 | _OrgYearGU: string; 47 | } 48 | 49 | export interface AttendanceEventTotal { 50 | PeriodTotal?: PeriodTotal[] | null; 51 | } 52 | 53 | export interface PeriodTotal { 54 | _Number: string; 55 | _Total: string; 56 | } 57 | -------------------------------------------------------------------------------- /src/lib/types/AuthToken.ts: -------------------------------------------------------------------------------- 1 | export interface AuthToken { 2 | _EncyToken: string; 3 | } 4 | -------------------------------------------------------------------------------- /src/lib/types/Documents.ts: -------------------------------------------------------------------------------- 1 | export interface Documents { 2 | StudentDocumentDatas: StudentDocumentDatas; 3 | '_xmlns:xsd': string; 4 | '_xmlns:xsi': string; 5 | _showDateColumn: string; 6 | _showDocNameColumn: string; 7 | _showDocCatColumn: string; 8 | _StudentGU: string; 9 | _StudentSSY: string; 10 | } 11 | 12 | export interface StudentDocumentDatas { 13 | StudentDocumentData?: StudentDocumentData[] | null; 14 | } 15 | 16 | export interface StudentDocumentData { 17 | _DocumentGU: string; 18 | _DocumentFileName: string; 19 | _DocumentDate: string; 20 | _DocumentType: string; 21 | _StudentGU: string; 22 | _DocumentComment: string; 23 | } 24 | -------------------------------------------------------------------------------- /src/lib/types/Gradebook.ts: -------------------------------------------------------------------------------- 1 | export interface Gradebook { 2 | ReportingPeriods: ReportingPeriods; 3 | ReportingPeriod: ReportPeriod; 4 | Courses: Courses; 5 | '_xmlns:xsd': string; 6 | '_xmlns:xsi': string; 7 | _Type: string; 8 | _ErrorMessage: string; 9 | _HideStandardGraphInd: string; 10 | _HideMarksColumnElementary: string; 11 | _HidePointsColumnElementary: string; 12 | _HidePercentSecondary: string; 13 | _DisplayStandardsData: string; 14 | _GBStandardsTabDefault: string; 15 | } 16 | 17 | export interface Courses { 18 | Course: Course[]; 19 | } 20 | 21 | export interface Course { 22 | Marks: Marks; 23 | _Period: string; 24 | _Title: string; 25 | _CourseName: string; 26 | _CourseID: string; 27 | _Room: string; 28 | _Staff: string; 29 | _StaffEMail: string; 30 | _StaffGU: string; 31 | _ImageType: string; 32 | _HighlightPercentageCutOffForProgressBar: string; 33 | _UsesRichContent: string; 34 | } 35 | 36 | export interface Marks { 37 | Mark: Mark; 38 | } 39 | 40 | export interface Mark { 41 | StandardViews: string; 42 | GradeCalculationSummary: GradeCalculationSummaryClass | string; 43 | Assignments: Assignments; 44 | AssignmentsSinceLastAccess: Assignments | string; 45 | _MarkName: string; 46 | _ShortMarkName: string; 47 | _CalculatedScoreString: string; 48 | _CalculatedScoreRaw: string; 49 | } 50 | 51 | export interface Assignments { 52 | Assignment?: AssignmentEntity[]; 53 | } 54 | 55 | export interface AssignmentEntity { 56 | Resources: ResourcesClass | string; 57 | Standards: string; 58 | _GradebookID: string; 59 | _Measure: string; 60 | _Type: string; 61 | _Date: string; 62 | _DueDate: string; 63 | _DisplayScore: string; 64 | _ScoreCalValue?: string; 65 | _TimeSincePost: string; 66 | _TotalSecondsSincePost: string; 67 | _ScoreMaxValue?: string; 68 | _ScoreType: 'Raw Score'; 69 | _Points: string; 70 | _Point?: string; 71 | _PointPossible?: string; 72 | _Notes: string; 73 | _TeacherID: string; 74 | _StudentID: string; 75 | _MeasureDescription: string; 76 | _HasDropBox: string; 77 | _DropStartDate: string; 78 | _DropEndDate: string; 79 | } 80 | 81 | export interface ResourcesClass { 82 | Resource: ResourceElement[] | ResourceElement; 83 | } 84 | 85 | export interface ResourceElement { 86 | _Type: Type; 87 | _ClassID: string; 88 | _GradebookID: string; 89 | _ResourceDate: Date; 90 | _ResourceDescription: string; 91 | _ResourceID: string; 92 | _ResourceName: string; 93 | _Sequence: string; 94 | _TeacherID: string; 95 | _url: string; 96 | _ServerFileName: string; 97 | _FileType?: FileType; 98 | } 99 | 100 | export enum FileType { 101 | ApplicationVndGoogleAppsFile = 'application/vnd.google-apps.file' 102 | } 103 | 104 | export enum Type { 105 | URL = 'URL' 106 | } 107 | 108 | export interface GradeCalculationSummaryClass { 109 | AssignmentGradeCalc: AssignmentGradeCalc[]; 110 | } 111 | 112 | export interface AssignmentGradeCalc { 113 | _Type: string; 114 | _Weight: string; 115 | _Points: string; 116 | _PointsPossible: string; 117 | _WeightedPct: string; 118 | _CalculatedMark: string; 119 | } 120 | 121 | export interface ReportPeriod { 122 | _GradePeriod: string; 123 | _StartDate: string; 124 | _EndDate: string; 125 | } 126 | 127 | export interface IndexedReportPeriod extends ReportPeriod { 128 | _Index: string; 129 | } 130 | 131 | export interface ReportingPeriods { 132 | ReportPeriod: IndexedReportPeriod[]; 133 | } 134 | -------------------------------------------------------------------------------- /src/lib/types/MailData.ts: -------------------------------------------------------------------------------- 1 | export interface MailData { 2 | FolderListViews: FolderListViews; 3 | InboxItemListings: InboxItemListings; 4 | SentItemListings: SentItemListings; 5 | DraftItemListings: DraftItemListings; 6 | ArchiveItemListings: string; 7 | OutboxItemListings: string; 8 | AllOtherFolderMessages: string; 9 | '_xmlns:xsd': string; 10 | '_xmlns:xsi': string; 11 | _PersonGU: string; 12 | _NewSignature: string; 13 | _ReplySignature: string; 14 | _EnableForwarding: string; 15 | _emailAddresses: string; 16 | _ParentRecipientAccessValue: string; 17 | _StudentRecipientAccessValue: string; 18 | _StaffRecipientAccessValue: string; 19 | _UserGroupsTabAllowed: string; 20 | _MassEmailTabAllowed: string; 21 | _StudentGroupsTabAllowed: string; 22 | _ContactListsTabAllowed: string; 23 | _StaffTabAllowed: string; 24 | _ParentsTabAllowed: string; 25 | _StudentsTabAllowed: string; 26 | _ClassesTabAllowed: string; 27 | _TeachersTabAllowed: string; 28 | _CounselorsTabAllowed: string; 29 | _ShowBCC: string; 30 | _SynergyMailForwardingEnabled: string; 31 | _SM_CheckUnreadMessagesTimeout: string; 32 | _SM_MaxAttachmentSizeMB: string; 33 | _SM_JobTitleColumnVisible: string; 34 | _SM_TypeColumnVisible: string; 35 | _SM_ContactLogOption: string; 36 | _SM_SupportingClassesDropDown: string; 37 | _SM_SupportingMessageSubjectAsElement: string; 38 | } 39 | 40 | export interface DraftItemListings { 41 | MessageXML: DraftItemListingsMessageXML[]; 42 | } 43 | 44 | export interface DraftItemListingsMessageXML { 45 | From: From; 46 | To: From | string; 47 | CC: string; 48 | BCC: string; 49 | Attachments: string; 50 | _SMMessageGU: string; 51 | _SMMsgPersonGU: string; 52 | _SendDateTime: string; 53 | _Subject: string; 54 | _MessageText: string; 55 | _FolderType: string; 56 | _Priority: Priority; 57 | _IsItDraft: string; 58 | _MailRead: string; 59 | _OriginalLanguageCode: string; 60 | _Translated: string; 61 | _SendDateTimeFormattedLong: string; 62 | _SendDateTimeFormattedShort: string; 63 | _ReadCount: string; 64 | _PersonCount: string; 65 | _UpdateContactLog: string; 66 | _TranslationComplete: string; 67 | _ContactCategory: ContactCategory; 68 | _AssociatedStudentGU: string; 69 | _IsMeeting: string; 70 | _VidConfGU: string; 71 | _MeetingStartDate: string; 72 | _Deleted: string; 73 | _NoReply: string; 74 | _EnforceSecurity: string; 75 | _MessageStatus: string; 76 | _MeetingEndDate: string; 77 | _CanJoinMeeting: string; 78 | _MeetingDateString: string; 79 | _MeetingHost: string; 80 | _Recurrence: string; 81 | _RecurrenceText: string; 82 | } 83 | 84 | export interface From { 85 | RecipientXML: RecipientXMLElement; 86 | } 87 | 88 | export interface RecipientXMLElement { 89 | _RecipientType: RecipientType; 90 | _GU?: string; 91 | _RecipientList: RecipientList; 92 | _GroupUserTypes: string; 93 | _Details1?: string; 94 | _Details2?: Details2; 95 | _ReadCount: string; 96 | _PersonCount: string; 97 | RecipientSectionList?: RecipientSectionListClass | string; 98 | _OrganizationYearGU?: string; 99 | } 100 | 101 | export interface RecipientSectionListClass { 102 | RecipientSection: RecipientSection; 103 | } 104 | 105 | /** 106 | * @interface RecipientSection 107 | * @property {string} _Period - Of the format period-period. If the period is 1, then the string will be 1-1. 108 | */ 109 | export interface RecipientSection { 110 | _SectionGU: string; 111 | _Teacher: string; 112 | _Period: string; 113 | _Term: Term; 114 | _SectionID: string; 115 | _CourseID: string; 116 | _Course: string; 117 | _Room: string; 118 | _Grade: string; 119 | } 120 | 121 | export enum Term { 122 | Yr = 'YR' 123 | } 124 | 125 | export enum Details2 { 126 | ParentsStudents = 'Parents & Students', 127 | Staff = 'Staff', 128 | Student = 'Student', 129 | Students = 'Students', 130 | Teacher = 'Teacher' 131 | } 132 | 133 | export enum RecipientList { 134 | Classes = 'Classes', 135 | External = 'External', 136 | MassEmail = 'MassEmail', 137 | Staff = 'Staff', 138 | Students = 'Students', 139 | Teachers = 'Teachers' 140 | } 141 | 142 | export enum RecipientType { 143 | From = 'From', 144 | To = 'To' 145 | } 146 | 147 | export enum ContactCategory { 148 | Me = 'ME', 149 | Sm = 'SM', 150 | Tcom = 'TCOM' 151 | } 152 | 153 | export enum Priority { 154 | None = 'None' 155 | } 156 | 157 | export interface FolderListViews { 158 | FolderListViewXML: FolderListViewXML[]; 159 | } 160 | 161 | export interface FolderListViewXML { 162 | _FolderType: string; 163 | _SmFolderGU: string; 164 | _FolderName: string; 165 | _Icon: string; 166 | _UnreadMessages: string; 167 | } 168 | 169 | export interface InboxItemListings { 170 | MessageXML: InboxItemListingsMessageXML[]; 171 | } 172 | 173 | export interface InboxItemListingsMessageXML { 174 | From: From; 175 | To: ToClass | string; 176 | CC: string; 177 | BCC: string; 178 | Attachments: AttachmentsClass | string; 179 | _SMMessageGU: string; 180 | _SMMsgPersonGU: string; 181 | _SendDateTime: string; 182 | _Subject: string; 183 | _MessageText: string; 184 | _FolderType: FolderType; 185 | _Priority: Priority; 186 | _IsItDraft: string; 187 | _MailRead: string; 188 | _OriginalLanguageCode: string; 189 | _Translated: string; 190 | _SendDateTimeFormattedLong: string; 191 | _SendDateTimeFormattedShort: string; 192 | _ReadCount: string; 193 | _PersonCount: string; 194 | _UpdateContactLog: string; 195 | _TranslationComplete: string; 196 | _ContactCategory: ContactCategory; 197 | _AssociatedStudentGU: string; 198 | _IsMeeting: string; 199 | _VidConfGU: string; 200 | _MeetingStartDate: string; 201 | _Deleted: string; 202 | _NoReply: string; 203 | _EnforceSecurity: string; 204 | _MessageStatus: MessageStatus; 205 | _MeetingEndDate: string; 206 | _CanJoinMeeting: string; 207 | _MeetingDateString: string; 208 | _MeetingHost: string; 209 | _Recurrence: string; 210 | _RecurrenceText: string; 211 | } 212 | 213 | export interface AttachmentsClass { 214 | AttachmentXML: AttachmentXML | AttachmentXML[]; 215 | } 216 | 217 | export interface AttachmentXML { 218 | _SmAttachmentGU: string; 219 | _DocumentName: string; 220 | } 221 | 222 | export interface ToClass { 223 | RecipientXML: RecipientXMLElement[] | RecipientXMLElement; 224 | } 225 | 226 | export enum FolderType { 227 | Inbox = 'Inbox' 228 | } 229 | 230 | export enum MessageStatus { 231 | Sent = 'Sent' 232 | } 233 | 234 | export interface SentItemListings { 235 | MessageXML: SentItemListingsMessageXML[]; 236 | } 237 | 238 | export interface SentItemListingsMessageXML { 239 | From: From; 240 | To: From; 241 | CC: string; 242 | BCC: string; 243 | Attachments: string; 244 | _SMMessageGU: string; 245 | _SMMsgPersonGU: string; 246 | _SendDateTime: string; 247 | _Subject: string; 248 | _MessageText: string; 249 | _FolderType: MessageStatus; 250 | _Priority: Priority; 251 | _IsItDraft: string; 252 | _MailRead: string; 253 | _OriginalLanguageCode: string; 254 | _Translated: string; 255 | _SendDateTimeFormattedLong: string; 256 | _SendDateTimeFormattedShort: string; 257 | _ReadCount: string; 258 | _PersonCount: string; 259 | _UpdateContactLog: string; 260 | _TranslationComplete: string; 261 | _ContactCategory: string; 262 | _AssociatedStudentGU: string; 263 | _IsMeeting: string; 264 | _VidConfGU: string; 265 | _MeetingStartDate: string; 266 | _Deleted: string; 267 | _NoReply: string; 268 | _EnforceSecurity: string; 269 | _MessageStatus: MessageStatus; 270 | _MeetingEndDate: string; 271 | _CanJoinMeeting: string; 272 | _MeetingDateString: string; 273 | _MeetingHost: string; 274 | _Recurrence: string; 275 | _RecurrenceText: string; 276 | _OriginalLanguageDescription?: string; 277 | _OriginalMessageText?: string; 278 | _OriginalSubjectText?: string; 279 | } 280 | -------------------------------------------------------------------------------- /src/lib/types/ReportCard.ts: -------------------------------------------------------------------------------- 1 | export interface ReportCardNotFound { 2 | '_xmlns:xsd': string; 3 | '_xmlns:xsi': string; 4 | } 5 | 6 | export interface ReportCard extends ReportCardNotFound { 7 | Base64Code: string; 8 | _DocumentGU: string; 9 | _FileName: string; 10 | _DocFileName: string; 11 | _DocType: string; 12 | } 13 | -------------------------------------------------------------------------------- /src/lib/types/StudentInfo.ts: -------------------------------------------------------------------------------- 1 | export interface StudentInfo { 2 | FormattedName: string; 3 | PermID: number; 4 | Gender: string; 5 | Grade: number; 6 | Photo: string; 7 | '_xmlns:xsd': string; 8 | '_xmlns:xsi': string; 9 | _Type: string; 10 | _ShowPhysicianAndDentistInfo: string; 11 | } 12 | -------------------------------------------------------------------------------- /src/routes/(authed)/+layout.svelte: -------------------------------------------------------------------------------- 1 | 31 | 32 | 35 | 36 | 37 | 38 | 39 | 40 |
41 |
42 | { 44 | drawerHidden = false; 45 | }} 46 | class="ml-0 text-white" 47 | /> 48 | GradeVue 49 |
{studentInfoState.data?.FormattedName}
50 |
51 | 52 |
53 | {@render children?.()} 54 |
55 |
56 | -------------------------------------------------------------------------------- /src/routes/(authed)/AppSidebar.svelte: -------------------------------------------------------------------------------- 1 | 37 | 38 | {#snippet sidebarLink( 39 | label: string, 40 | href: string, 41 | Icon: Component, 42 | onclick?: () => void, 43 | subtext?: Snippet 44 | )} 45 | 46 | 47 | 48 | 49 | 50 | {#if subtext} 51 | {@render subtext()} 52 | {/if} 53 | 54 | 55 | {/snippet} 56 | 57 | 58 | 59 | 60 | 67 | 68 | {#if page.url.pathname.startsWith('/grades')} 69 |
  • 70 | 74 | 75 | Grades 76 | {#if page.params.index} 77 | 78 | {:else} 79 | 80 | {/if} 81 | 82 | {#if page.params.index && currentGradebookState?.data} 83 | 102 | {/if} 103 |
  • 104 | {:else} 105 | {@render sidebarLink('Grades', '/grades', AddressBookOutline)} 106 | {/if} 107 | 108 | {@render sidebarLink('Attendance', '/attendance', BellOutline)} 109 | 110 | {@render sidebarLink('Documents', '/documents', FolderOpenOutline)} 111 | 112 | {@render sidebarLink('Mail', '/mail', MailBoxOutline)} 113 |
    114 | 115 | 116 | {#if $installPrompt.prompt} 117 |
    118 | {@render sidebarLink('Install Web App', '#', DownloadOutline, () => 119 | $installPrompt.prompt?.() 120 | )} 121 |
    122 | {/if} 123 | 124 | {@render sidebarLink('Feedback', '/feedback', AnnotationOutline)} 125 | 126 | {@render sidebarLink( 127 | studentInfoState.data?.FormattedName ?? '', 128 | '/studentinfo', 129 | UserCircleOutline 130 | )} 131 | 132 | {@render sidebarLink('Log Out', '/login', ArrowRightToBracketOutline, logOut)} 133 |
    134 |
    135 |
    136 | -------------------------------------------------------------------------------- /src/routes/(authed)/attendance/+page.svelte: -------------------------------------------------------------------------------- 1 | 42 | 43 | 44 | Attendance - GradeVue 45 | 46 | 47 | 48 | 49 | {#if attendanceState.lastRefresh !== undefined} 50 | loadAttendance(true)} 54 | /> 55 | {/if} 56 | 57 | {#if attendanceState.data} 58 | 59 | {#each attendanceState.data.Absences.Absence ?? [] as absence} 60 | 61 |
    62 | {fullDateFormatter.format(new Date(absence._AbsenceDate))} 63 | {#if getAbsenceType(absence.Periods.Period ?? [])} 64 | 68 | {getAbsenceType(absence.Periods.Period ?? [])} 69 | 70 | {/if} 71 |
    72 |
      73 | {#each absence.Periods.Period?.filter((course) => course._Name) ?? [] as period} 74 |
    1. {removeClassID(period._Course)}: {period._Name}
    2. 75 | {/each} 76 |
    77 |
    78 | {/each} 79 |
    80 | {/if} 81 | -------------------------------------------------------------------------------- /src/routes/(authed)/attendance/attendance.svelte.ts: -------------------------------------------------------------------------------- 1 | import { loadRecord, LocalStorageKey, type RecordState } from '$lib'; 2 | import { acc } from '$lib/account.svelte'; 3 | import type { Attendance } from '$lib/types/Attendance'; 4 | 5 | export const attendanceState: RecordState = $state({ loaded: false }); 6 | 7 | export const loadAttendance = async (forceRefresh = false) => { 8 | const { studentAccount } = acc; 9 | if (!studentAccount) return; 10 | 11 | await loadRecord( 12 | attendanceState, 13 | () => studentAccount.attendance(), 14 | LocalStorageKey.attendance, 15 | 1000 * 60 * 60, 16 | forceRefresh 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /src/routes/(authed)/documents/+page.svelte: -------------------------------------------------------------------------------- 1 | 45 | 46 | 47 | Documents - GradeVue 48 | 49 | 50 | 51 | 52 | {#if documentsState.lastRefresh !== undefined} 53 | loadDocuments(true)} 57 | /> 58 | {/if} 59 | 60 | {#if documentsState.data} 61 | 62 | 63 | {@render documentsList(documentDatas)} 64 | 65 | {#each documentCategories as category} 66 | 67 | {@render documentsList( 68 | documentDatas.filter((documentData) => documentData._DocumentType === category) 69 | )} 70 | 71 | {/each} 72 | 73 | {/if} 74 | 75 | {#snippet documentsList(documents: StudentDocumentData[])} 76 |
      77 | {#each documents as documentData} 78 |
    1. 79 | 84 |

      {documentData._DocumentComment}

      85 | 86 | 87 | {documentData._DocumentType} 88 | 89 |
      90 |
    2. 91 | {/each} 92 |
    93 | {/snippet} 94 | -------------------------------------------------------------------------------- /src/routes/(authed)/documents/document/+page.svelte: -------------------------------------------------------------------------------- 1 | 34 | 35 | 36 | Document - GradeVue 37 | 38 | 39 | {#if reportCardURLPromise} 40 | {#await reportCardURLPromise} 41 | 42 | {:then} 43 | 44 | {:catch error} 45 |
    46 | 47 |

    {error}

    48 | 49 | 50 |
    51 |
    52 | {/await} 53 | {/if} 54 | -------------------------------------------------------------------------------- /src/routes/(authed)/documents/documents.svelte.ts: -------------------------------------------------------------------------------- 1 | import { loadRecord, LocalStorageKey, type RecordState } from '$lib'; 2 | import { acc } from '$lib/account.svelte'; 3 | import type { Documents } from '$lib/types/Documents'; 4 | 5 | export const documentsState: RecordState = $state({ loaded: false }); 6 | 7 | export const loadDocuments = async (forceRefresh = false) => { 8 | const { studentAccount } = acc; 9 | if (!studentAccount) return; 10 | 11 | await loadRecord( 12 | documentsState, 13 | () => studentAccount.documents(), 14 | LocalStorageKey.documents, 15 | 1000 * 60 * 60 * 24, 16 | forceRefresh 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /src/routes/(authed)/feedback/+page.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | Feedback - GradeVue 9 | 10 | 11 |
    12 | 16 | 17 |

    18 | Report an issue or suggest a feature on GitHub 19 |

    20 |
    21 | 22 | 23 | 24 |

    Send us an email

    25 |
    26 |
    27 | -------------------------------------------------------------------------------- /src/routes/(authed)/grades/+layout.svelte: -------------------------------------------------------------------------------- 1 | 24 | 25 | 26 | 27 | {#if currentGradebookState?.lastRefresh !== undefined} 28 | 32 | showGradebook(gradebooksState.overrideIndex ?? gradebooksState.activeIndex, true)} 33 | /> 34 | {/if} 35 | 36 | {#if gradebooksState.overrideIndex && gradebooksState.records && gradebooksState.activeIndex && currentGradebookState?.data} 37 | 38 | 39 | Viewing reporting period {currentGradebookState.data.ReportingPeriod._GradePeriod} 40 | 41 | 42 | 45 | 46 | {/if} 47 | 48 | {@render children?.()} 49 | -------------------------------------------------------------------------------- /src/routes/(authed)/grades/+page.svelte: -------------------------------------------------------------------------------- 1 | 39 | 40 | 41 | Grades - GradeVue 42 | 43 | 44 | {#if allPeriods && currentPeriod && currentPeriodIndex !== undefined && currentGradebookState?.data} 45 |
    46 |
    47 | 56 | 57 | 58 | {#each allPeriods ?? [] as period, index} 59 | { 61 | dropdownOpen = false; 62 | 63 | showGradebook(index); 64 | }} 65 | class="flex items-center" 66 | > 67 | {#if period._GradePeriod === currentPeriod._GradePeriod} 68 | 69 | {/if} 70 | {period._GradePeriod} 71 | 72 | {/each} 73 | 74 |
    75 | 76 | {#if currentGradebookState.data.Courses.Course.map((course) => course.Marks.Mark._CalculatedScoreString).every((score) => score === 'N/A')} 77 | 78 | 79 | It looks like you don't have any grades yet in this reporting period. 80 | 81 | {#if currentPeriodIndex > 0} 82 | 86 | {/if} 87 | 88 | {/if} 89 | 90 |
      91 | {#each currentGradebookState.data.Courses.Course ?? [] as { _Title: title, Marks: { Mark: { _CalculatedScoreString: grade, _CalculatedScoreRaw: percent, Assignments } } }, index} 92 |
    1. 93 | 97 | {removeClassID(title)} 98 | 99 | {#if Assignments.Assignment && getUnseenAssignmentsCount(Assignments.Assignment) > 0} 100 | 101 | {getUnseenAssignmentsCount(Assignments.Assignment)} new 102 | 103 | {/if} 104 | 105 | 110 | 111 | 118 |
    2. 119 | {/each} 120 |
    121 |
    122 | {/if} 123 | -------------------------------------------------------------------------------- /src/routes/(authed)/grades/[index]/+page.server.ts: -------------------------------------------------------------------------------- 1 | export const prerender = 'auto'; 2 | -------------------------------------------------------------------------------- /src/routes/(authed)/grades/[index]/+page.svelte: -------------------------------------------------------------------------------- 1 | 195 | 196 | 197 | {courseName} - GradeVue 198 | 199 | 200 | {#if synergyCourse} 201 |
    202 |
    203 | 204 | {courseName} 205 | 206 | 207 | {#if hypotheticalMode && !categories && !rawGradeCalcMatches} 208 | 209 | {/if} 210 | {#if value} 211 | 217 | {/if} 218 | 219 |
    220 | {#if pinChart} 221 | {@render chart()} 222 | {/if} 223 |
    224 | 225 | {#if !pinChart} 226 | {@render chart()} 227 | {/if} 228 | 229 | {#snippet chart()} 230 | {#if rawGradeCalcMatches} 231 | 236 | {/if} 237 | {/snippet} 238 | 239 | {#if categories && gradeCategories && totalCategory} 240 |
    241 | 242 | 243 | Category 244 | Grade 245 | Weight 246 | Points 247 | 248 | 249 | {#each gradeCategories.toSorted() as category} 250 | 251 | {category.name} 252 | 253 | {#if category.pointsEarned !== 0 || category.pointsPossible !== 0} 254 | {category.gradeLetter} 255 | {#if rawGradeCalcMatches} 256 | ({Math.round( 257 | calculateGradePercentage(category.pointsEarned, category.pointsPossible) * 258 | 1000 259 | ) / 1000}%) 260 | {/if} 261 | {/if} 262 | 263 | {category.weightPercentage}% 264 | 265 | {category.pointsEarned} / {category.pointsPossible} 266 | 267 | 268 | {/each} 269 | 270 | Total 271 | 272 | {totalCategory.gradeLetter} 273 | {#if rawGradeCalcMatches} 274 | ({Math.round( 275 | calculateGradePercentage( 276 | totalCategory.pointsEarned, 277 | totalCategory.pointsPossible 278 | ) * 1000 279 | ) / 1000}%) 280 | {/if} 281 | 282 | 283 | 284 | {totalCategory.pointsEarned} / {totalCategory.pointsPossible} 285 | 286 | 287 | 288 |
    289 |
    290 | {/if} 291 | 292 | {#if !rawGradeCalcMatches} 293 | 294 | 295 | 296 |
    297 | 316 | 317 | {#if calcWarningOpen} 318 | 319 | Your class's official grade percentage does not match GradeVue's calculated grade 320 | percentage. This could mean that there are hidden assignments that GradeVue can't see, 321 | or that GradeVue isn't calculating your grade correctly. Your overall grade is still 322 | correct, but other things might be off. 323 | 324 | {/if} 325 |
    326 |
    327 | {/if} 328 | 329 | {#if unseenAssignments.length > 0 && !hypotheticalMode} 330 |
    331 | 332 | {unseenAssignments.length} new assignments 333 | 343 | 344 |
    345 | {/if} 346 | 347 |
    348 | 349 |
    350 | Hypothetical Mode 351 | 352 |
    353 |
    354 | 355 | Hypothetical mode allows you to see what your grade would be if you got a certain score on an 356 | assignment. 357 | 358 | 359 | {#if rawGradeCalcMatches} 360 | Pin chart when scrolling 361 | {/if} 362 | 363 | {#if hypotheticalMode} 364 |
    365 | 369 | 370 | 374 |
    375 | {/if} 376 |
    377 | 378 | {#if synergyAssignments.length > 0 || hypotheticalMode} 379 |
    380 | 381 | 382 | {@render assignmentList()} 383 | 384 | 385 | {#each [...new Set(realAssignments 386 | .map((assignment) => assignment.category) 387 | .toSorted())] as categoryName} 388 | 389 | {@render assignmentList((assignment) => assignment.category === categoryName)} 390 | 391 | {/each} 392 | 393 |
    394 | {:else} 395 |
    396 | 397 | 398 | 399 | Looks like this this course doesn't have any grades yet. 400 | 401 |
    402 | {/if} 403 | {/if} 404 | 405 | {#snippet assignmentList(filter?: (assignment: Assignment) => boolean)} 406 |
      407 | {#if hypotheticalMode} 408 | {#each reactiveAssignments as assignment, i} 409 | {#if !filter || filter(assignment)} 410 | {@render boundAssignmentSnippet(assignment, reactiveAssignments, i)} 411 | {/if} 412 | {/each} 413 | {:else} 414 | {#each assignments as assignment} 415 | {#if !filter || filter(assignment)} 416 | {@render assignmentSnippet(assignment)} 417 | {/if} 418 | {/each} 419 | {/if} 420 |
    421 | {/snippet} 422 | 423 | {#snippet assignmentSnippet( 424 | { 425 | name, 426 | id, 427 | pointsEarned, 428 | pointsPossible, 429 | unscaledPoints, 430 | extraCredit, 431 | gradePercentageChange, 432 | notForGrade, 433 | hidden, 434 | category, 435 | date 436 | }: RealAssignment | Flowed, 437 | showCategory = true 438 | )} 439 |
  • 440 | 453 |
  • 454 | {/snippet} 455 | 456 | {#snippet boundAssignmentSnippet( 457 | { gradePercentageChange, hidden, newHypothetical, date }: ReactiveAssignment, 458 | reactiveAssignments: ReactiveAssignment[], 459 | i: number, 460 | showCategory = true 461 | )} 462 |
  • 463 | {#if showCategory} 464 | category.name)} 475 | {date} 476 | editable={true} 477 | {recalculateGradePercentage} 478 | /> 479 | {:else} 480 | category.name)} 490 | {date} 491 | editable={true} 492 | {recalculateGradePercentage} 493 | /> 494 | {/if} 495 |
  • 496 | {/snippet} 497 | -------------------------------------------------------------------------------- /src/routes/(authed)/grades/[index]/AssignmentCard.svelte: -------------------------------------------------------------------------------- 1 | 79 | 80 | 83 |
    84 | {#if editable} 85 | 86 | 87 | {#if categoryDropdownOptions.length > 0} 88 | 92 | 93 | 94 | {#each categoryDropdownOptions as categoryOption} 95 | { 97 | category = categoryOption; 98 | categoryDropdownOpen = false; 99 | recalculateGradePercentage(); 100 | }} 101 | > 102 | {categoryOption} 103 | 104 | {/each} 105 | 106 | {/if} 107 | {:else} 108 | {name} 109 | {/if} 110 | {#if category && (!editable || categoryDropdownOptions.length === 0) && !showHypotheticalLabel} 111 | 112 | {category} 113 | 114 | {/if} 115 | {#if unscaledPoints} 116 | Scaled 117 | {/if} 118 | {#if pointsEarned === undefined} 119 | Not Graded 120 | {/if} 121 | {#if notForGrade} 122 | 123 | {#if editable} 124 | 125 | Not For Grade 126 | 127 | {:else} 128 | Not For Grade 129 | {/if} 130 | 131 | {/if} 132 | {#if extraCredit} 133 | 134 | {#if editable} 135 | 136 | Extra Credit 137 | 138 | {:else} 139 | Extra Credit 140 | {/if} 141 | 142 | {/if} 143 | {#if hidden} 144 | 145 | Teachers can choose to have assignments hidden from the assignment list but still calculated 146 | toward your grade. GradeVue can reveal these assignments. 147 | 148 | 149 | Hidden Assignments 150 | 151 | {/if} 152 | {#if showHypotheticalLabel} 153 | Hypothetical Assignment 154 | {/if} 155 | {#if date} 156 | 157 | {/if} 158 |
    159 | 160 |
    161 | {#if gradePercentageChange !== undefined} 162 | {#if percentageChange < 0} 163 | 164 | {percentageChange}% 165 | 166 | {:else if percentageChange > 0} 167 | 168 | +{percentageChange}% 169 | 170 | {:else if !notForGrade && pointsEarned && !isNaN(pointsEarned)} 171 | +0% 172 | {/if} 173 | {/if} 174 | 175 | {#if unscaledPoints} 176 | 177 | ({unscaledPoints.pointsEarned}/{unscaledPoints.pointsPossible}) 178 | 179 | {/if} 180 | 181 | {#if editable} 182 |
    183 | 189 | / 190 | 196 |
    197 | {:else if pointsEarned === undefined} 198 | {pointsPossible} 199 | {:else} 200 | {pointsEarned}/{pointsPossible} 201 | {#if percentage && percentage !== Infinity} 202 | {Math.round(percentage * 100) / 100}% 203 | {/if} 204 | {/if} 205 |
    206 | 207 | {#if pointsEarned !== undefined || editable} 208 |
    216 | -------------------------------------------------------------------------------- /src/routes/(authed)/grades/[index]/GradeChart.svelte: -------------------------------------------------------------------------------- 1 | 146 | 147 |
    148 | 149 |
    150 | -------------------------------------------------------------------------------- /src/routes/(authed)/grades/gradebook.svelte.ts: -------------------------------------------------------------------------------- 1 | import { LocalStorageKey, type LocalStorageCache, type RecordState } from '$lib'; 2 | import { acc } from '$lib/account.svelte'; 3 | import type { Gradebook, ReportPeriod } from '$lib/types/Gradebook'; 4 | import { SvelteSet } from 'svelte/reactivity'; 5 | 6 | interface GradebooksLocalStorageCache { 7 | records: (undefined | LocalStorageCache)[]; 8 | activeIndex: number; 9 | overrideIndex?: number; 10 | } 11 | 12 | interface GradebooksState { 13 | records?: (undefined | RecordState)[]; 14 | activeIndex?: number; 15 | overrideIndex?: number; 16 | } 17 | 18 | export const gradebooksState: GradebooksState = $state({}); 19 | 20 | export const getCurrentGradebookState = (gradebooksState: GradebooksState) => 21 | gradebooksState.records && gradebooksState.activeIndex 22 | ? gradebooksState.records[gradebooksState.overrideIndex ?? gradebooksState.activeIndex] 23 | : undefined; 24 | 25 | export const seenAssignmentIDs = new SvelteSet(); 26 | 27 | const cacheExpirationTime = 1000 * 60; 28 | 29 | export const getPeriodIndex = (period: ReportPeriod, periods: ReportPeriod[]) => 30 | periods.map((period) => period._GradePeriod).findIndex((name) => name === period._GradePeriod); 31 | 32 | const saveGradebooksState = () => { 33 | if (!gradebooksState.records || !gradebooksState.activeIndex) 34 | throw new Error('Gradebook state not initialized before saving'); 35 | 36 | const cache: GradebooksLocalStorageCache = { 37 | records: gradebooksState.records.map((record) => { 38 | if (!record || !record.data || !record.lastRefresh) return undefined; 39 | 40 | return { data: record.data, lastRefresh: record.lastRefresh }; 41 | }), 42 | activeIndex: gradebooksState.activeIndex, 43 | overrideIndex: gradebooksState.overrideIndex 44 | }; 45 | 46 | localStorage.setItem(LocalStorageKey.gradebook, JSON.stringify(cache)); 47 | }; 48 | 49 | export const loadGradebooks = async () => { 50 | const { studentAccount } = acc; 51 | if (!studentAccount || gradebooksState.records || gradebooksState.activeIndex !== undefined) 52 | return; 53 | 54 | // Load seen assignment ids from localStorage 55 | const seenIDsStr = localStorage.getItem(LocalStorageKey.seenAssignmentIDs); 56 | if (seenIDsStr) { 57 | try { 58 | const seenIDs: string[] = JSON.parse(seenIDsStr); 59 | seenIDs.forEach((id) => seenAssignmentIDs.add(id)); 60 | } catch (e) { 61 | console.error(e); 62 | localStorage.removeItem(LocalStorageKey.seenAssignmentIDs); 63 | } 64 | } 65 | 66 | // Try to load the state from the localStorage cache 67 | const cacheStr = localStorage.getItem(LocalStorageKey.gradebook); 68 | if (cacheStr) { 69 | try { 70 | const cache: GradebooksLocalStorageCache = JSON.parse(cacheStr); 71 | 72 | gradebooksState.records = cache.records.map((lsCache) => { 73 | if (!lsCache) return lsCache; 74 | 75 | const record: RecordState = { 76 | data: lsCache.data, 77 | loaded: false, 78 | lastRefresh: lsCache.lastRefresh 79 | }; 80 | 81 | return record; 82 | }); 83 | gradebooksState.activeIndex = cache.activeIndex; 84 | gradebooksState.overrideIndex = cache.overrideIndex; 85 | } catch (err) { 86 | console.error(err); 87 | localStorage.removeItem(LocalStorageKey.gradebook); 88 | } 89 | } 90 | 91 | // If the state hasn't been initalized yet (e.g. if localStorage was cleared) 92 | if (gradebooksState.activeIndex === undefined || gradebooksState.records === undefined) { 93 | // Request the active gradebook to get the active index 94 | const activeGradebook = await studentAccount.gradebook(); 95 | 96 | const activeIndex = getPeriodIndex( 97 | activeGradebook.ReportingPeriod, 98 | activeGradebook.ReportingPeriods.ReportPeriod 99 | ); 100 | gradebooksState.activeIndex = activeIndex; 101 | 102 | // Initialize the records array (will be undefined at first) 103 | gradebooksState.records ??= Array(activeGradebook.ReportingPeriods.ReportPeriod.length); 104 | 105 | // Save the active gradebook to the records array 106 | gradebooksState.records[activeIndex] = { 107 | data: activeGradebook, 108 | loaded: true, 109 | lastRefresh: Date.now() 110 | }; 111 | } 112 | 113 | // Load the gradebook currently being viewed 114 | await showGradebook(gradebooksState.overrideIndex); 115 | 116 | // Save the state to localStorage 117 | saveGradebooksState(); 118 | }; 119 | 120 | export const showGradebook = async (overrideIndex?: number, forceRefresh = false) => { 121 | const { studentAccount } = acc; 122 | if (!studentAccount) return; 123 | 124 | if (gradebooksState.records === undefined || gradebooksState.activeIndex === undefined) 125 | throw new Error('Gradebooks state is not initialized'); 126 | 127 | // If the override index is the same as the active index, override isn't needed 128 | if (overrideIndex === gradebooksState.activeIndex) gradebooksState.overrideIndex = undefined; 129 | else gradebooksState.overrideIndex = overrideIndex; 130 | 131 | const index = overrideIndex ?? gradebooksState.activeIndex; 132 | 133 | // Set the state of the requested gradebook to loading in preparation for possible cache refresh 134 | gradebooksState.records[index] ??= { loaded: false }; 135 | 136 | if (gradebooksState.records[index].loaded) gradebooksState.records[index].loaded = false; 137 | 138 | // Check if cache is expired 139 | let refresh = true; 140 | if ( 141 | !forceRefresh && 142 | Date.now() - (gradebooksState.records[index].lastRefresh ?? 0) < cacheExpirationTime 143 | ) { 144 | gradebooksState.records[index].loaded = true; 145 | refresh = false; 146 | } 147 | 148 | // If expired or refreshing manually, refresh 149 | if (refresh) { 150 | try { 151 | const newGradebook = await studentAccount.gradebook(overrideIndex); 152 | 153 | gradebooksState.records[index].data = newGradebook; 154 | gradebooksState.records[index].lastRefresh = Date.now(); 155 | 156 | // If it retrieved the active gradebook 157 | if (overrideIndex === undefined) { 158 | // Check if the active index has changed since the last time it was set 159 | const newIndex = getPeriodIndex( 160 | newGradebook.ReportingPeriod, 161 | newGradebook.ReportingPeriods.ReportPeriod 162 | ); 163 | 164 | // If it has, update the active index 165 | if (newIndex !== index) gradebooksState.activeIndex = newIndex; 166 | } 167 | } catch (err) { 168 | console.error(err); 169 | } 170 | } 171 | 172 | gradebooksState.records[index].loaded = true; 173 | 174 | saveGradebooksState(); 175 | }; 176 | 177 | export const saveSeenAssignments = () => 178 | localStorage.setItem(LocalStorageKey.seenAssignmentIDs, JSON.stringify([...seenAssignmentIDs])); 179 | -------------------------------------------------------------------------------- /src/routes/(authed)/mail/+page.svelte: -------------------------------------------------------------------------------- 1 | 43 | 44 | 45 | Mail - GradeVue 46 | 47 | 48 | 49 | 50 | {#if mailDataState.lastRefresh !== undefined} 51 | loadMailData(true)} 55 | /> 56 | {/if} 57 | 58 | {#if mailDataState.data} 59 |

    Inbox

    60 | 61 |
      62 | {#each mailDataState.data.InboxItemListings.MessageXML as message} 63 |
    1. { 65 | touchscreen = true; 66 | }} 67 | > 68 | 71 |
    2. 72 | {/each} 73 |
    74 | 75 | {#if openedMessage} 76 | 83 | 89 | 90 | {/if} 91 | {/if} 92 | -------------------------------------------------------------------------------- /src/routes/(authed)/mail/MessageCard.svelte: -------------------------------------------------------------------------------- 1 | 29 | 30 | 31 |
    32 |

    {message._Subject}

    33 | 34 |
    35 | 36 | 37 | {message.From.RecipientXML._Details1} ({message.From.RecipientXML._Details2}) 38 | 39 | 40 | 41 | 42 | {#if linkCount > 0} 43 | 44 | {linkCount} 45 | {linkCount === 1 ? 'Link' : 'Links'} 46 | 47 | {/if} 48 | 49 | {#if attachments} 50 | 51 | {attachments.length} 52 | {attachments.length === 1 ? 'Attachment' : 'Attachments'} 53 | 54 | {/if} 55 |
    56 |
    57 |
    58 | -------------------------------------------------------------------------------- /src/routes/(authed)/mail/MessageView.svelte: -------------------------------------------------------------------------------- 1 | 36 | 37 |
      38 |
    1. 39 | {message._SendDateTimeFormattedLong} 40 |
    2. 41 |
    3. 42 | From: {from._Details1} 43 | ({from._Details2}) 44 |
    4. 45 | 46 | {#if recipients} 47 |
    5. 48 | {#if recipients.length > 1} 49 |
      50 | To: 51 | 66 |
      67 | 68 | {#if showRecipients} 69 |
        70 | {#each recipients as recipient} 71 |
      • {recipient._Details1} ({recipient._Details2})
      • 72 | {/each} 73 |
      74 | {/if} 75 | {:else} 76 | To: {recipients[0]._Details1} ({recipients[0]._Details2}) 77 | {/if} 78 |
    6. 79 | {/if} 80 |
    81 | 82 | 137 | 138 | {#if attachments || links.length > 0} 139 |
      140 | {#each links as link} 141 |
    • 142 | 149 | 150 | {new URL(link).hostname} 151 | 152 |
    • 153 | {/each} 154 | 155 | {#each attachments ?? [] as attachment} 156 |
    • 157 | 164 | 165 | {attachment._DocumentName} 166 | 167 |
    • 168 | {/each} 169 |
    170 | {/if} 171 | -------------------------------------------------------------------------------- /src/routes/(authed)/mail/attachment/+page.svelte: -------------------------------------------------------------------------------- 1 | 34 | 35 | 36 | Attachment - GradeVue 37 | 38 | 39 | {#if attachmentURLPromise} 40 | {#await attachmentURLPromise} 41 | 42 | {:then} 43 | 44 | {:catch error} 45 |
    46 | 47 |

    {error}

    48 | 49 | 50 |
    51 |
    52 | {/await} 53 | {/if} 54 | -------------------------------------------------------------------------------- /src/routes/(authed)/mail/mailData.svelte.ts: -------------------------------------------------------------------------------- 1 | import { loadRecord, LocalStorageKey, type RecordState } from '$lib'; 2 | import { acc } from '$lib/account.svelte'; 3 | import type { MailData } from '$lib/types/MailData'; 4 | 5 | export const mailDataState: RecordState = $state({ loaded: false }); 6 | 7 | export const loadMailData = async (forceRefresh = false) => { 8 | const { studentAccount } = acc; 9 | if (!studentAccount) return; 10 | 11 | await loadRecord( 12 | mailDataState, 13 | () => studentAccount.mailData(), 14 | LocalStorageKey.mailData, 15 | 1000 * 60 * 60, 16 | forceRefresh 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /src/routes/(authed)/studentinfo/+page.svelte: -------------------------------------------------------------------------------- 1 | 49 | 50 | 51 | Student Info - GradeVue 52 | 53 | 54 | 55 | 56 | {#if studentInfoState.lastRefresh !== undefined} 57 | loadStudentInfo(true)} 61 | /> 62 | {/if} 63 | 64 |
    65 | {#if studentInfoState.data} 66 | 67 | Student Portrait 72 |
    73 |

    74 | {studentInfoState.data.FormattedName} 75 |

    76 | 77 | {studentInfoState.data.PermID} 78 | 79 | Grade {studentInfoState.data.Grade} 80 | {studentInfoState.data.Gender} 81 |
    82 |
    83 | {/if} 84 | 85 | 86 | 87 | Developer Tools 88 | 89 | 105 | 106 | 123 | 124 | 125 | 126 | {#each dataSources as dataSource} 127 | 128 | {dataSource} 129 | {@render toolButton('Copy', () => copy(dataSource))} 130 | {@render toolButton('Paste', () => paste(dataSource))} 131 | {@render toolButton('Delete', () => remove(dataSource))} 132 | 133 | {/each} 134 | 135 |
    136 |
    137 |
    138 |
    139 | 140 | {#snippet toolButton(name: string, func: () => void)} 141 | 142 | 143 | 144 | {/snippet} 145 | -------------------------------------------------------------------------------- /src/routes/(authed)/studentinfo/studentInfo.svelte.ts: -------------------------------------------------------------------------------- 1 | import { loadRecord, LocalStorageKey, type RecordState } from '$lib'; 2 | import { acc } from '$lib/account.svelte'; 3 | import type { StudentInfo } from '$lib/types/StudentInfo'; 4 | 5 | export const studentInfoState: RecordState = $state({ loaded: false }); 6 | 7 | export const loadStudentInfo = async (forceRefresh = false) => { 8 | const { studentAccount } = acc; 9 | if (!studentAccount) return; 10 | 11 | await loadRecord( 12 | studentInfoState, 13 | () => studentAccount.studentInfo(), 14 | LocalStorageKey.studentInfo, 15 | 1000 * 60 * 60 * 24 * 30, 16 | forceRefresh 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /src/routes/+error.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | {page.status} {page.error?.message} - GradeVue 8 | 9 | 10 |
    11 | 12 |

    {page.status} {page.error?.message}

    13 | 14 | 15 |
    16 |
    17 | -------------------------------------------------------------------------------- /src/routes/+layout.server.ts: -------------------------------------------------------------------------------- 1 | export const prerender = true; 2 | -------------------------------------------------------------------------------- /src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | {@render children?.()} 8 | -------------------------------------------------------------------------------- /src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 19 | 20 | 21 | Gradevue - The best way to check your grades on StudentVue 22 | 23 | 24 |
    25 | 26 |

    GradeVue

    27 |

    28 | An better way to view your grades on StudentVue that shows all of your Synergy information in 29 | one place. 30 |

    31 |
    32 | 33 | 34 |
    35 |
    36 | 37 |
    38 | 39 |

    40 | 41 | Grade Calculator 42 |

    43 |

    44 | GradeVue's powerful Hypothetical Mode allows you to calculate what your grade would be if 45 | you got a score on an assignment, as well as how each assignment affects your grade. 46 |

    47 |
    48 | 49 |

    50 | 51 | Shows Hidden Assignments 52 |

    53 |

    54 | GradeVue is able to reveal hidden assignments and will factor them into your grade 55 | calculations. You never have to worry about your grade calculations being inaccurate! 56 |

    57 |
    58 | 59 |

    60 | 61 | Private Login 62 |

    63 |

    64 | GradeVue does not have access to your login information. When you use GradeVue, your device 65 | connects directly to StudentVue. We never see your password or your grades! 66 | Learn more 67 |

    68 |
    69 | 70 |

    71 | 72 | Attendance and more 73 |

    74 |

    75 | GradeVue breaks down your attendance by day and shows what periods you missed. It also shows 76 | your report cards, documents, and messages. 77 |

    78 |
    79 |
    80 | 81 | 90 |
    91 | -------------------------------------------------------------------------------- /src/routes/login/+page.svelte: -------------------------------------------------------------------------------- 1 | 63 | 64 | 65 | Log In - Gradevue 66 | 67 | 68 | 69 | 70 | {#if loginError} 71 |
    72 | 73 | 74 | Couldn't log in 75 |

    {loginError}

    76 |
    77 |
    78 | {/if} 79 | 80 |
    81 | 82 |
    83 |

    Sign in to GradeVue

    84 | 94 | 109 | 110 | 111 | Advanced 112 | 116 | 117 | 118 | 119 |
    120 |
    121 |
    122 | -------------------------------------------------------------------------------- /src/routes/privacy/+page.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | Privacy - GradeVue 11 | 12 | 13 |
    14 | 15 |

    About the privacy of GradeVue

    16 |

    17 | I developed GradeVue during winter break after using SynergyPlus and being concerned about the 18 | privacy of the service. GradeVue is designed to keep students' information private the entire 19 | time you use the service. It's similar to using the StudentVue app; even though you're not 20 | viewing the offical website, you're still accessing your grades securely, just in a more 21 | convenient manner. 22 |

    23 | 24 |

    25 | When a student logs in to GradeVue, their username and password are sent directly to the 26 | offical website, using the built-in 27 | 31 | Synergy API 32 | . This is different from SynergyPlus, which sends the login information through their 33 | servers without ever informing the user. 34 |

    35 | 36 | 37 | If you have questions or concerns about GradeVue, please contact me at 38 | {email}. 39 | 40 |
    41 |
    42 | -------------------------------------------------------------------------------- /src/routes/signup/+layout.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | Sign Up - GradeVue 9 | 10 | 11 |
    12 | 13 |

    Sign up for GradeVue

    14 | 15 | {@render children?.()} 16 |
    17 |
    18 | -------------------------------------------------------------------------------- /src/routes/signup/+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |

    How do you sign in to StudentVue?

    6 | 7 |
    8 | 9 | 10 |
    11 | 12 | If you've used SynergyPlus before, you're already signed up. Just use the same password to 13 | log in. 14 | 15 | -------------------------------------------------------------------------------- /src/routes/signup/google/+page.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 |

    7 | GradeVue isn't able to use Sign in with Google to sign you in. You'll need to create a password 8 | for StudentVue that GradeVue can sign you in with instead. 9 | 10 | You'll still be able to use Sign in with Google with StudentVue afterwards. 11 | 12 |

    13 | 14 | 17 | 18 | 19 | 20 | If you've used SynergyPlus before, you can use the same password to 21 | log in to GradeVue. 22 | 23 | 24 |

    25 | You should receive an email that will contain a link to set your password. This may take a few minutes. 28 | Once you've created your password, you can 29 | log in. 30 |

    31 | -------------------------------------------------------------------------------- /src/routes/signup/password/+page.svelte: -------------------------------------------------------------------------------- 1 |

    2 | Use your StudentVue password to 3 | log in. 4 |

    5 | -------------------------------------------------------------------------------- /src/tests/gradebook.test.ts: -------------------------------------------------------------------------------- 1 | import { parseSynergyAssignment, type RealAssignment } from '$lib/assignments'; 2 | import type { AssignmentEntity } from '$lib/types/Gradebook'; 3 | import { expect, test } from 'bun:test'; 4 | 5 | test('Normal Assignment', () => { 6 | const synergyAssignment: AssignmentEntity = { 7 | Resources: '', 8 | Standards: '', 9 | _GradebookID: '123456', 10 | _Measure: 'Ch. 3 Quiz', 11 | _Type: 'Quiz', 12 | _Date: '1/1/2024', 13 | _DueDate: '1/1/2024', 14 | _DisplayScore: '3 out of 4', 15 | _ScoreCalValue: '3', 16 | _TimeSincePost: '4h', 17 | _TotalSecondsSincePost: '17554.6023012', 18 | _ScoreMaxValue: '4', 19 | _ScoreType: 'Raw Score', 20 | _Points: '3 / 4', 21 | _Point: '3', 22 | _PointPossible: '4', 23 | _Notes: '', 24 | _TeacherID: '11111', 25 | _StudentID: '22222', 26 | _MeasureDescription: '', 27 | _HasDropBox: 'false', 28 | _DropStartDate: '1/1/2024', 29 | _DropEndDate: '1/1/2024' 30 | }; 31 | 32 | expect(parseSynergyAssignment(synergyAssignment)).toEqual({ 33 | name: 'Ch. 3 Quiz', 34 | id: '123456', 35 | pointsEarned: 3, 36 | pointsPossible: 4, 37 | unscaledPoints: undefined, 38 | extraCredit: false, 39 | gradePercentageChange: undefined, 40 | notForGrade: false, 41 | hidden: false, 42 | category: 'Quiz', 43 | date: new Date('1/1/2024'), 44 | newHypothetical: false 45 | }); 46 | }); 47 | 48 | test('Normal Assignment - _Point is empty string', () => { 49 | const synergyAssignment: AssignmentEntity = { 50 | Resources: '', 51 | Standards: '', 52 | _GradebookID: '123456', 53 | _Measure: 'Ch. 3 Quiz', 54 | _Type: 'Quiz', 55 | _Date: '1/1/2024', 56 | _DueDate: '1/1/2024', 57 | _DisplayScore: '0 out of 4', 58 | _ScoreCalValue: '0', 59 | _TimeSincePost: '4h', 60 | _TotalSecondsSincePost: '17554.6023012', 61 | _ScoreMaxValue: '4', 62 | _ScoreType: 'Raw Score', 63 | _Points: '/ 4', 64 | _Point: '', 65 | _PointPossible: '4', 66 | _Notes: '', 67 | _TeacherID: '11111', 68 | _StudentID: '22222', 69 | _MeasureDescription: '', 70 | _HasDropBox: 'false', 71 | _DropStartDate: '1/1/2024', 72 | _DropEndDate: '1/1/2024' 73 | }; 74 | 75 | expect(parseSynergyAssignment(synergyAssignment)).toEqual({ 76 | name: 'Ch. 3 Quiz', 77 | id: '123456', 78 | pointsEarned: 0, 79 | pointsPossible: 4, 80 | unscaledPoints: undefined, 81 | extraCredit: false, 82 | gradePercentageChange: undefined, 83 | notForGrade: false, 84 | hidden: false, 85 | category: 'Quiz', 86 | date: new Date('1/1/2024'), 87 | newHypothetical: false 88 | }); 89 | }); 90 | 91 | test('Not Graded Assignment - Points Possible', () => { 92 | const synergyAssignment: AssignmentEntity = { 93 | Resources: '', 94 | Standards: '', 95 | _GradebookID: '123456', 96 | _Measure: 'Ch. 3 Quiz', 97 | _Type: 'Quiz', 98 | _Date: '1/1/2024', 99 | _DueDate: '1/1/2024', 100 | _DisplayScore: 'Not Graded', 101 | _TimeSincePost: '4h', 102 | _TotalSecondsSincePost: '17554.6023012', 103 | _ScoreMaxValue: '4', 104 | _ScoreType: 'Raw Score', 105 | _Points: '4 Points Possible', 106 | _Notes: '', 107 | _TeacherID: '11111', 108 | _StudentID: '22222', 109 | _MeasureDescription: '', 110 | _HasDropBox: 'false', 111 | _DropStartDate: '1/1/2024', 112 | _DropEndDate: '1/1/2024' 113 | }; 114 | 115 | const match: RealAssignment = { 116 | name: 'Ch. 3 Quiz', 117 | id: '123456', 118 | pointsEarned: undefined, 119 | pointsPossible: 4, 120 | unscaledPoints: undefined, 121 | extraCredit: false, 122 | gradePercentageChange: undefined, 123 | notForGrade: false, 124 | hidden: false, 125 | category: 'Quiz', 126 | date: new Date('1/1/2024'), 127 | newHypothetical: false 128 | }; 129 | 130 | expect(parseSynergyAssignment(synergyAssignment)).toEqual(match); 131 | 132 | synergyAssignment._ScoreMaxValue = undefined; 133 | 134 | expect(parseSynergyAssignment(synergyAssignment)).toEqual(match); 135 | }); 136 | 137 | test('Not Graded Assignment - No Points Possible', () => { 138 | const synergyAssignment: AssignmentEntity = { 139 | Resources: '', 140 | Standards: '', 141 | _GradebookID: '123456', 142 | _Measure: 'Ch. 3 Quiz', 143 | _Type: 'Quiz', 144 | _Date: '1/1/2024', 145 | _DueDate: '1/1/2024', 146 | _DisplayScore: 'Not Graded', 147 | _TimeSincePost: '4h', 148 | _TotalSecondsSincePost: '17554.6023012', 149 | _ScoreType: 'Raw Score', 150 | _Points: 'Points Possible', 151 | _Notes: '', 152 | _TeacherID: '11111', 153 | _StudentID: '22222', 154 | _MeasureDescription: '', 155 | _HasDropBox: 'false', 156 | _DropStartDate: '1/1/2024', 157 | _DropEndDate: '1/1/2024' 158 | }; 159 | 160 | expect(parseSynergyAssignment(synergyAssignment)).toEqual({ 161 | name: 'Ch. 3 Quiz', 162 | id: '123456', 163 | pointsEarned: undefined, 164 | pointsPossible: undefined, 165 | unscaledPoints: undefined, 166 | extraCredit: false, 167 | gradePercentageChange: undefined, 168 | notForGrade: false, 169 | hidden: false, 170 | category: 'Quiz', 171 | date: new Date('1/1/2024'), 172 | newHypothetical: false 173 | }); 174 | }); 175 | 176 | test('Extra Credit Assignment', () => { 177 | const synergyAssignment: AssignmentEntity = { 178 | Resources: '', 179 | Standards: '', 180 | _GradebookID: '123456', 181 | _Measure: 'Ch. 3 Quiz', 182 | _Type: 'Quiz', 183 | _Date: '1/1/2024', 184 | _DueDate: '1/1/2024', 185 | _DisplayScore: '3 out of 4', 186 | _ScoreCalValue: '3', 187 | _TimeSincePost: '4h', 188 | _TotalSecondsSincePost: '17554.6023012', 189 | _ScoreMaxValue: '4', 190 | _ScoreType: 'Raw Score', 191 | _Points: '3 /', 192 | _Point: '3', 193 | _PointPossible: '', 194 | _Notes: '', 195 | _TeacherID: '11111', 196 | _StudentID: '22222', 197 | _MeasureDescription: '', 198 | _HasDropBox: 'false', 199 | _DropStartDate: '1/1/2024', 200 | _DropEndDate: '1/1/2024' 201 | }; 202 | 203 | expect(parseSynergyAssignment(synergyAssignment)).toEqual({ 204 | name: 'Ch. 3 Quiz', 205 | id: '123456', 206 | pointsEarned: 3, 207 | pointsPossible: 4, 208 | unscaledPoints: undefined, 209 | extraCredit: true, 210 | gradePercentageChange: undefined, 211 | notForGrade: false, 212 | hidden: false, 213 | category: 'Quiz', 214 | date: new Date('1/1/2024'), 215 | newHypothetical: false 216 | }); 217 | }); 218 | 219 | test('Not For Grading Assignment', () => { 220 | const synergyAssignment: AssignmentEntity = { 221 | Resources: '', 222 | Standards: '', 223 | _GradebookID: '123456', 224 | _Measure: 'Ch. 3 Quiz', 225 | _Type: 'Quiz', 226 | _Date: '1/1/2024', 227 | _DueDate: '1/1/2024', 228 | _DisplayScore: '3 out of 4', 229 | _ScoreCalValue: '3', 230 | _TimeSincePost: '4h', 231 | _TotalSecondsSincePost: '17554.6023012', 232 | _ScoreMaxValue: '4', 233 | _ScoreType: 'Raw Score', 234 | _Points: '3 / 4', 235 | _Point: '3', 236 | _PointPossible: '4', 237 | _Notes: '(Not For Grading)', 238 | _TeacherID: '11111', 239 | _StudentID: '22222', 240 | _MeasureDescription: '', 241 | _HasDropBox: 'false', 242 | _DropStartDate: '1/1/2024', 243 | _DropEndDate: '1/1/2024' 244 | }; 245 | 246 | expect(parseSynergyAssignment(synergyAssignment)).toEqual({ 247 | name: 'Ch. 3 Quiz', 248 | id: '123456', 249 | pointsEarned: 3, 250 | pointsPossible: 4, 251 | unscaledPoints: undefined, 252 | extraCredit: false, 253 | gradePercentageChange: undefined, 254 | notForGrade: true, 255 | hidden: false, 256 | category: 'Quiz', 257 | date: new Date('1/1/2024'), 258 | newHypothetical: false 259 | }); 260 | }); 261 | 262 | test('Scaled Assignment', () => { 263 | const synergyAssignment: AssignmentEntity = { 264 | Resources: '', 265 | Standards: '', 266 | _GradebookID: '123456', 267 | _Measure: 'Ch. 3 Quiz', 268 | _Type: 'Quiz', 269 | _Date: '1/1/2024', 270 | _DueDate: '1/1/2024', 271 | _DisplayScore: '3 out of 4', 272 | _ScoreCalValue: '3', 273 | _TimeSincePost: '4h', 274 | _TotalSecondsSincePost: '17554.6023012', 275 | _ScoreMaxValue: '4', 276 | _ScoreType: 'Raw Score', 277 | _Points: '6 / 8', 278 | _Point: '6', 279 | _PointPossible: '8', 280 | _Notes: '', 281 | _TeacherID: '11111', 282 | _StudentID: '22222', 283 | _MeasureDescription: '', 284 | _HasDropBox: 'false', 285 | _DropStartDate: '1/1/2024', 286 | _DropEndDate: '1/1/2024' 287 | }; 288 | 289 | expect(parseSynergyAssignment(synergyAssignment)).toEqual({ 290 | name: 'Ch. 3 Quiz', 291 | id: '123456', 292 | pointsEarned: 6, 293 | pointsPossible: 8, 294 | unscaledPoints: { 295 | pointsEarned: 3, 296 | pointsPossible: 4 297 | }, 298 | extraCredit: false, 299 | gradePercentageChange: undefined, 300 | notForGrade: false, 301 | hidden: false, 302 | category: 'Quiz', 303 | date: new Date('1/1/2024'), 304 | newHypothetical: false 305 | }); 306 | }); 307 | -------------------------------------------------------------------------------- /static/apple-touch-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PurelyAnecdotal/gradevue/dc485a5a89070ad17de79e9a8c62bf162e71d81e/static/apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PurelyAnecdotal/gradevue/dc485a5a89070ad17de79e9a8c62bf162e71d81e/static/favicon.ico -------------------------------------------------------------------------------- /static/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /static/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "GradeVue", 3 | "description": "An improved StudentVue experience, with calculators and tools to help you understand your grade.", 4 | "start_url": "/grades", 5 | "display": "standalone", 6 | "background_color": "#111827", 7 | "theme_color": "#ef562f", 8 | "icons": [ 9 | { 10 | "src": "/pwa-64x64.png", 11 | "sizes": "64x64", 12 | "type": "image/png" 13 | }, 14 | { 15 | "src": "/pwa-192x192.png", 16 | "sizes": "192x192", 17 | "type": "image/png" 18 | }, 19 | { 20 | "src": "/pwa-512x512.png", 21 | "sizes": "512x512", 22 | "type": "image/png" 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /static/maskable-icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PurelyAnecdotal/gradevue/dc485a5a89070ad17de79e9a8c62bf162e71d81e/static/maskable-icon-512x512.png -------------------------------------------------------------------------------- /static/pwa-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PurelyAnecdotal/gradevue/dc485a5a89070ad17de79e9a8c62bf162e71d81e/static/pwa-192x192.png -------------------------------------------------------------------------------- /static/pwa-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PurelyAnecdotal/gradevue/dc485a5a89070ad17de79e9a8c62bf162e71d81e/static/pwa-512x512.png -------------------------------------------------------------------------------- /static/pwa-64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PurelyAnecdotal/gradevue/dc485a5a89070ad17de79e9a8c62bf162e71d81e/static/pwa-64x64.png -------------------------------------------------------------------------------- /static/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-auto'; 2 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 3 | 4 | /** @type {import('@sveltejs/kit').Config} */ 5 | const config = { 6 | // Consult https://kit.svelte.dev/docs/integrations#preprocessors 7 | // for more information about preprocessors 8 | preprocess: vitePreprocess(), 9 | 10 | kit: { 11 | // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. 12 | // If your environment is not supported or you settled on a specific environment, switch out the adapter. 13 | // See https://kit.svelte.dev/docs/adapters for more information about adapters. 14 | adapter: adapter() 15 | } 16 | }; 17 | 18 | export default config; 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true, 12 | "moduleResolution": "bundler" 13 | } 14 | // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias 15 | // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files 16 | // 17 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 18 | // from the referenced tsconfig.json - TypeScript does not merge them in 19 | } 20 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { sveltekit } from '@sveltejs/kit/vite'; 2 | import tailwindcss from '@tailwindcss/vite'; 3 | import { defineConfig } from 'vite'; 4 | 5 | export default defineConfig({ 6 | plugins: [tailwindcss(), sveltekit()] 7 | }); 8 | --------------------------------------------------------------------------------