Usage Statistics and Website Demographics
Adds two new features to collect and show useful analytics. Usage Statistics: * Begin tracking daily active users who log in and interact with major features of the website each day, such as the chat room, forum and gallery. Demographics page: * For marketing, the home page now shows live statistics about the breakdown of content (explicit vs. non-explicit) on the site, and the /insights page gives a lot more data in detail. * Show the percent split in photo gallery content and how many users opt-in or share explicit content on the site. * Show high-level demographics of the members (by age range, gender, orientation) Misc cleanup: * Rearrange model list in data export to match the auto-create statements. * In data exports, include the forum_memberships, push_notifications and usage_statistics tables.
This commit is contained in:
parent
8d9588b039
commit
2f31d678d0
|
@ -83,6 +83,9 @@ const (
|
||||||
|
|
||||||
// Chat room status refresh interval.
|
// Chat room status refresh interval.
|
||||||
ChatStatusRefreshInterval = 30 * time.Second
|
ChatStatusRefreshInterval = 30 * time.Second
|
||||||
|
|
||||||
|
// Cache TTL for the demographics page.
|
||||||
|
DemographicsCacheTTL = time.Hour
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
|
@ -154,6 +154,13 @@ func Landing() http.HandlerFunc {
|
||||||
// of time where they can exist in chat but change their name on the site.
|
// of time where they can exist in chat but change their name on the site.
|
||||||
worker.GetChatStatistics().SetOnlineNow(currentUser.Username)
|
worker.GetChatStatistics().SetOnlineNow(currentUser.Username)
|
||||||
|
|
||||||
|
// Ping their chat login usage statistic.
|
||||||
|
go func() {
|
||||||
|
if err := models.LogDailyChatUser(currentUser); err != nil {
|
||||||
|
log.Error("LogDailyChatUser(%s): error logging this user's chat statistic: %s", currentUser.Username, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// Redirect them to the chat room.
|
// Redirect them to the chat room.
|
||||||
templates.Redirect(w, strings.TrimSuffix(chatURL, "/")+"/?jwt="+ss)
|
templates.Redirect(w, strings.TrimSuffix(chatURL, "/")+"/?jwt="+ss)
|
||||||
return
|
return
|
||||||
|
|
|
@ -97,6 +97,13 @@ func Thread() http.HandlerFunc {
|
||||||
// Is the current user subscribed to notifications on this thread?
|
// Is the current user subscribed to notifications on this thread?
|
||||||
_, isSubscribed := models.IsSubscribed(currentUser, "threads", thread.ID)
|
_, isSubscribed := models.IsSubscribed(currentUser, "threads", thread.ID)
|
||||||
|
|
||||||
|
// Ping this user as having used the forums today.
|
||||||
|
go func() {
|
||||||
|
if err := models.LogDailyForumUser(currentUser); err != nil {
|
||||||
|
log.Error("LogDailyForumUser(%s): error logging their usage statistic: %s", currentUser.Username, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
var vars = map[string]interface{}{
|
var vars = map[string]interface{}{
|
||||||
"Forum": forum,
|
"Forum": forum,
|
||||||
"Thread": thread,
|
"Thread": thread,
|
||||||
|
|
57
pkg/controller/index/demographics.go
Normal file
57
pkg/controller/index/demographics.go
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
package index
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/models/demographic"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/session"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Demographics page (/insights) to show a peek at website demographics.
|
||||||
|
func Demographics() http.HandlerFunc {
|
||||||
|
tmpl := templates.Must("demographics.html")
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var (
|
||||||
|
refresh = r.FormValue("refresh") == "true"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Are we refreshing? Check if an admin is logged in.
|
||||||
|
if refresh {
|
||||||
|
currentUser, err := session.CurrentUser(r)
|
||||||
|
if err != nil {
|
||||||
|
session.FlashError(w, r, "You must be logged in to do that!")
|
||||||
|
templates.Redirect(w, r.URL.Path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do the refresh?
|
||||||
|
if currentUser.IsAdmin {
|
||||||
|
_, err := demographic.Refresh()
|
||||||
|
if err != nil {
|
||||||
|
session.FlashError(w, r, "Refreshing the insights: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templates.Redirect(w, r.URL.Path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get website statistics to show on home page.
|
||||||
|
demo, err := demographic.Get()
|
||||||
|
if err != nil {
|
||||||
|
session.FlashError(w, r, "Couldn't get website statistics: %s", err)
|
||||||
|
templates.Redirect(w, "/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
vars := map[string]interface{}{
|
||||||
|
"Demographic": demo,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
|
@ -5,6 +5,7 @@ import (
|
||||||
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/config"
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
"code.nonshy.com/nonshy/website/pkg/log"
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/models/demographic"
|
||||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -18,7 +19,17 @@ func Create() http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tmpl.Execute(w, r, nil); err != nil {
|
// Get website statistics to show on home page.
|
||||||
|
demo, err := demographic.Get()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("demographic.Get: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
vars := map[string]interface{}{
|
||||||
|
"Demographic": demo,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/config"
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
"code.nonshy.com/nonshy/website/pkg/models"
|
"code.nonshy.com/nonshy/website/pkg/models"
|
||||||
"code.nonshy.com/nonshy/website/pkg/session"
|
"code.nonshy.com/nonshy/website/pkg/session"
|
||||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||||
|
@ -121,6 +122,13 @@ func SiteGallery() http.HandlerFunc {
|
||||||
likeMap := models.MapLikes(currentUser, "photos", photoIDs)
|
likeMap := models.MapLikes(currentUser, "photos", photoIDs)
|
||||||
commentMap := models.MapCommentCounts("photos", photoIDs)
|
commentMap := models.MapCommentCounts("photos", photoIDs)
|
||||||
|
|
||||||
|
// Ping this user as having used the forums today.
|
||||||
|
go func() {
|
||||||
|
if err := models.LogDailyGalleryUser(currentUser); err != nil {
|
||||||
|
log.Error("LogDailyGalleryUser(%s): error logging their usage statistic: %s", currentUser.Username, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
var vars = map[string]interface{}{
|
var vars = map[string]interface{}{
|
||||||
"IsSiteGallery": true,
|
"IsSiteGallery": true,
|
||||||
"Photos": photos,
|
"Photos": photos,
|
||||||
|
|
|
@ -48,9 +48,8 @@ func LoginRequired(handler http.Handler) http.Handler {
|
||||||
// Ping LastLoginAt for long lived sessions, but not if impersonated.
|
// Ping LastLoginAt for long lived sessions, but not if impersonated.
|
||||||
var pingLastLoginAt bool
|
var pingLastLoginAt bool
|
||||||
if time.Since(user.LastLoginAt) > config.LastLoginAtCooldown && !session.Impersonated(r) {
|
if time.Since(user.LastLoginAt) > config.LastLoginAtCooldown && !session.Impersonated(r) {
|
||||||
user.LastLoginAt = time.Now()
|
|
||||||
pingLastLoginAt = true
|
pingLastLoginAt = true
|
||||||
if err := user.Save(); err != nil {
|
if err := user.PingLastLoginAt(); err != nil {
|
||||||
log.Error("LoginRequired: couldn't refresh LastLoginAt for user %s: %s", user.Username, err)
|
log.Error("LoginRequired: couldn't refresh LastLoginAt for user %s: %s", user.Username, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -59,6 +59,7 @@ func DeleteUser(user *models.User) error {
|
||||||
{"IP Addresses", DeleteIPAddresses},
|
{"IP Addresses", DeleteIPAddresses},
|
||||||
{"Push Notifications", DeletePushNotifications},
|
{"Push Notifications", DeletePushNotifications},
|
||||||
{"Forum Memberships", DeleteForumMemberships},
|
{"Forum Memberships", DeleteForumMemberships},
|
||||||
|
{"Usage Statistics", DeleteUsageStatistics},
|
||||||
}
|
}
|
||||||
for _, item := range todo {
|
for _, item := range todo {
|
||||||
if err := item.Fn(user.ID); err != nil {
|
if err := item.Fn(user.ID); err != nil {
|
||||||
|
@ -406,3 +407,13 @@ func DeleteForumMemberships(userID uint64) error {
|
||||||
).Delete(&models.ForumMembership{})
|
).Delete(&models.ForumMembership{})
|
||||||
return result.Error
|
return result.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteUsageStatistics scrubs data for deleting a user.
|
||||||
|
func DeleteUsageStatistics(userID uint64) error {
|
||||||
|
log.Error("DeleteUser: DeleteUsageStatistics(%d)", userID)
|
||||||
|
result := models.DB.Where(
|
||||||
|
"user_id = ?",
|
||||||
|
userID,
|
||||||
|
).Delete(&models.UsageStatistic{})
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
|
195
pkg/models/demographic/demographic.go
Normal file
195
pkg/models/demographic/demographic.go
Normal file
|
@ -0,0 +1,195 @@
|
||||||
|
// Package demographic handles periodic report pulling for high level website statistics.
|
||||||
|
//
|
||||||
|
// It powers the home page and insights page, where a prospective new user can get a peek inside
|
||||||
|
// the website to see the split between regular vs. explicit content and membership statistics.
|
||||||
|
//
|
||||||
|
// These database queries could get slow so the demographics are pulled and cached in this package.
|
||||||
|
package demographic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Demographic is the top level container struct with all the insights needed for front-end display.
|
||||||
|
type Demographic struct {
|
||||||
|
Computed bool
|
||||||
|
LastUpdated time.Time
|
||||||
|
Photo Photo
|
||||||
|
People People
|
||||||
|
}
|
||||||
|
|
||||||
|
// Photo statistics show the split between explicit and non-explicit content.
|
||||||
|
type Photo struct {
|
||||||
|
Total int64
|
||||||
|
NonExplicit int64
|
||||||
|
Explicit int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// People statistics.
|
||||||
|
type People struct {
|
||||||
|
Total int64
|
||||||
|
ExplicitOptIn int64
|
||||||
|
ExplicitPhoto int64
|
||||||
|
ByAgeRange map[string]int64
|
||||||
|
ByGender map[string]int64
|
||||||
|
ByOrientation map[string]int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemberDemographic of members.
|
||||||
|
type MemberDemographic struct {
|
||||||
|
Label string // e.g. age range "18-25" or gender
|
||||||
|
Count int64
|
||||||
|
Percent int
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dynamic calculation methods on the above types (percentages, etc.)
|
||||||
|
*/
|
||||||
|
|
||||||
|
func (d Demographic) PrettyPrint() string {
|
||||||
|
b, err := json.MarshalIndent(d, "", "\t")
|
||||||
|
if err != nil {
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Photo) PercentExplicit() int {
|
||||||
|
if p.Total == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return int((float64(p.Explicit) / float64(p.Total)) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Photo) PercentNonExplicit() int {
|
||||||
|
if p.Total == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return int((float64(p.NonExplicit) / float64(p.Total)) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p People) PercentExplicit() int {
|
||||||
|
if p.Total == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return int((float64(p.ExplicitOptIn) / float64(p.Total)) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p People) PercentExplicitPhoto() int {
|
||||||
|
if p.Total == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return int((float64(p.ExplicitPhoto) / float64(p.Total)) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p People) IterAgeRanges() []MemberDemographic {
|
||||||
|
var (
|
||||||
|
result = []MemberDemographic{}
|
||||||
|
values = []string{}
|
||||||
|
unique = map[string]struct{}{}
|
||||||
|
)
|
||||||
|
|
||||||
|
for age := range p.ByAgeRange {
|
||||||
|
if _, ok := unique[age]; !ok {
|
||||||
|
values = append(values, age)
|
||||||
|
}
|
||||||
|
unique[age] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(values)
|
||||||
|
for _, age := range values {
|
||||||
|
var (
|
||||||
|
count = p.ByAgeRange[age]
|
||||||
|
pct int
|
||||||
|
)
|
||||||
|
if p.Total > 0 {
|
||||||
|
pct = int((float64(count) / float64(p.Total)) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result, MemberDemographic{
|
||||||
|
Label: age,
|
||||||
|
Count: p.ByAgeRange[age],
|
||||||
|
Percent: pct,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p People) IterGenders() []MemberDemographic {
|
||||||
|
var (
|
||||||
|
result = []MemberDemographic{}
|
||||||
|
values = append(config.Gender, "")
|
||||||
|
unique = map[string]struct{}{}
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, option := range values {
|
||||||
|
unique[option] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
for gender := range p.ByGender {
|
||||||
|
if _, ok := unique[gender]; !ok {
|
||||||
|
values = append(values, gender)
|
||||||
|
unique[gender] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, gender := range values {
|
||||||
|
var (
|
||||||
|
count = p.ByGender[gender]
|
||||||
|
pct int
|
||||||
|
)
|
||||||
|
if p.Total > 0 {
|
||||||
|
pct = int((float64(count) / float64(p.Total)) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result, MemberDemographic{
|
||||||
|
Label: gender,
|
||||||
|
Count: p.ByGender[gender],
|
||||||
|
Percent: pct,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p People) IterOrientations() []MemberDemographic {
|
||||||
|
var (
|
||||||
|
result = []MemberDemographic{}
|
||||||
|
values = append(config.Orientation, "")
|
||||||
|
unique = map[string]struct{}{}
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, option := range values {
|
||||||
|
unique[option] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
for orientation := range p.ByOrientation {
|
||||||
|
if _, ok := unique[orientation]; !ok {
|
||||||
|
values = append(values, orientation)
|
||||||
|
unique[orientation] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, gender := range values {
|
||||||
|
var (
|
||||||
|
count = p.ByOrientation[gender]
|
||||||
|
pct int
|
||||||
|
)
|
||||||
|
if p.Total > 0 {
|
||||||
|
pct = int((float64(count) / float64(p.Total)) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result, MemberDemographic{
|
||||||
|
Label: gender,
|
||||||
|
Count: p.ByOrientation[gender],
|
||||||
|
Percent: pct,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
284
pkg/models/demographic/queries.go
Normal file
284
pkg/models/demographic/queries.go
Normal file
|
@ -0,0 +1,284 @@
|
||||||
|
package demographic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cached statistics (in case the queries are heavy to hit too often).
|
||||||
|
var (
|
||||||
|
cachedDemographic Demographic
|
||||||
|
cacheMu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get the current cached demographics result.
|
||||||
|
func Get() (Demographic, error) {
|
||||||
|
// Do we have the results cached?
|
||||||
|
var result = cachedDemographic
|
||||||
|
if !result.Computed || time.Since(result.LastUpdated) > config.DemographicsCacheTTL {
|
||||||
|
cacheMu.Lock()
|
||||||
|
defer cacheMu.Unlock()
|
||||||
|
|
||||||
|
// If we have a race of threads: e.g. one request is pulling the stats and the second is locked.
|
||||||
|
// Check if we have an updated result from the first thread.
|
||||||
|
if time.Since(cachedDemographic.LastUpdated) < config.DemographicsCacheTTL {
|
||||||
|
return cachedDemographic, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the latest.
|
||||||
|
res, err := Generate()
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedDemographic = res
|
||||||
|
}
|
||||||
|
|
||||||
|
return cachedDemographic, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh the demographics cache, pulling fresh results from the database every time.
|
||||||
|
func Refresh() (Demographic, error) {
|
||||||
|
cacheMu.Lock()
|
||||||
|
cachedDemographic = Demographic{}
|
||||||
|
cacheMu.Unlock()
|
||||||
|
return Get()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate the demographics result.
|
||||||
|
func Generate() (Demographic, error) {
|
||||||
|
if !config.Current.Database.IsPostgres {
|
||||||
|
return cachedDemographic, errors.New("this feature requires a PostgreSQL database")
|
||||||
|
}
|
||||||
|
|
||||||
|
result := Demographic{
|
||||||
|
Computed: true,
|
||||||
|
LastUpdated: time.Now(),
|
||||||
|
Photo: PhotoStatistics(),
|
||||||
|
People: PeopleStatistics(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PeopleStatistics pulls various metrics about users of the website.
|
||||||
|
func PeopleStatistics() People {
|
||||||
|
var result = People{
|
||||||
|
ByAgeRange: map[string]int64{},
|
||||||
|
ByGender: map[string]int64{},
|
||||||
|
ByOrientation: map[string]int64{},
|
||||||
|
}
|
||||||
|
|
||||||
|
type record struct {
|
||||||
|
MetricType string
|
||||||
|
MetricValue string
|
||||||
|
MetricCount int64
|
||||||
|
}
|
||||||
|
var records []record
|
||||||
|
res := models.DB.Raw(`
|
||||||
|
-- Users who opt in/out of explicit content
|
||||||
|
WITH subquery_explicit AS (
|
||||||
|
SELECT
|
||||||
|
SUM(CASE WHEN explicit IS TRUE THEN 1 ELSE 0 END) AS explicit_count,
|
||||||
|
SUM(CASE WHEN explicit IS NOT TRUE THEN 1 ELSE 0 END) AS non_explicit_count
|
||||||
|
FROM users
|
||||||
|
WHERE users.status = 'active'
|
||||||
|
AND users.certified IS TRUE
|
||||||
|
),
|
||||||
|
|
||||||
|
-- Users who share at least one explicit photo on public
|
||||||
|
subquery_explicit_photo AS (
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS user_count
|
||||||
|
FROM users
|
||||||
|
WHERE users.status = 'active'
|
||||||
|
AND users.certified IS TRUE
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM photos
|
||||||
|
WHERE photos.user_id = users.id
|
||||||
|
AND photos.explicit IS TRUE
|
||||||
|
AND photos.visibility = 'public'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
-- User counts by age
|
||||||
|
subquery_ages AS (
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 0 AND 25 THEN '18-25'
|
||||||
|
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 26 and 35 THEN '26-35'
|
||||||
|
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 36 and 45 THEN '36-45'
|
||||||
|
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 46 and 55 THEN '46-55'
|
||||||
|
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 56 and 65 THEN '56-65'
|
||||||
|
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 66 and 75 THEN '66-75'
|
||||||
|
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 76 and 85 THEN '76-85'
|
||||||
|
ELSE '86+'
|
||||||
|
END AS age_range,
|
||||||
|
COUNT(*) AS user_count
|
||||||
|
FROM
|
||||||
|
users
|
||||||
|
GROUP BY
|
||||||
|
CASE
|
||||||
|
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 0 AND 25 THEN '18-25'
|
||||||
|
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 26 and 35 THEN '26-35'
|
||||||
|
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 36 and 45 THEN '36-45'
|
||||||
|
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 46 and 55 THEN '46-55'
|
||||||
|
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 56 and 65 THEN '56-65'
|
||||||
|
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 66 and 75 THEN '66-75'
|
||||||
|
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 76 and 85 THEN '76-85'
|
||||||
|
ELSE '86+'
|
||||||
|
END
|
||||||
|
),
|
||||||
|
|
||||||
|
-- User counts by gender
|
||||||
|
subquery_gender AS (
|
||||||
|
SELECT
|
||||||
|
profile_fields.value AS gender,
|
||||||
|
COUNT(*) AS user_count
|
||||||
|
FROM users
|
||||||
|
JOIN profile_fields ON profile_fields.user_id = users.id
|
||||||
|
WHERE users.status = 'active'
|
||||||
|
AND users.certified IS TRUE
|
||||||
|
AND profile_fields.name = 'gender'
|
||||||
|
GROUP BY profile_fields.value
|
||||||
|
),
|
||||||
|
|
||||||
|
-- User counts by orientation
|
||||||
|
subquery_orientation AS (
|
||||||
|
SELECT
|
||||||
|
profile_fields.value AS orientation,
|
||||||
|
COUNT(*) AS user_count
|
||||||
|
FROM users
|
||||||
|
JOIN profile_fields ON profile_fields.user_id = users.id
|
||||||
|
WHERE users.status = 'active'
|
||||||
|
AND users.certified IS TRUE
|
||||||
|
AND profile_fields.name = 'orientation'
|
||||||
|
GROUP BY profile_fields.value
|
||||||
|
)
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
'ExplicitCount' AS metric_type,
|
||||||
|
'explicit' AS metric_value,
|
||||||
|
explicit_count AS metric_count
|
||||||
|
FROM subquery_explicit
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
'ExplicitPhotoCount' AS metric_type,
|
||||||
|
'count' AS metric_value,
|
||||||
|
user_count AS metric_count
|
||||||
|
FROM subquery_explicit_photo
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
'ExplicitCount' AS metric_type,
|
||||||
|
'non_explicit' AS metric_value,
|
||||||
|
non_explicit_count AS metric_count
|
||||||
|
FROM subquery_explicit
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
'AgeCounts' AS metric_type,
|
||||||
|
age_range AS metric_value,
|
||||||
|
user_count AS metric_count
|
||||||
|
FROM subquery_ages
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
'GenderCount' AS metric_type,
|
||||||
|
gender AS metric_value,
|
||||||
|
user_count AS metric_count
|
||||||
|
FROM subquery_gender
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
'OrientationCount' AS metric_type,
|
||||||
|
orientation AS metric_value,
|
||||||
|
user_count AS metric_count
|
||||||
|
FROM subquery_orientation
|
||||||
|
`).Scan(&records)
|
||||||
|
if res.Error != nil {
|
||||||
|
log.Error("PeopleStatistics: %s", res.Error)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ingest the records.
|
||||||
|
for _, row := range records {
|
||||||
|
switch row.MetricType {
|
||||||
|
case "ExplicitCount":
|
||||||
|
result.Total += row.MetricCount
|
||||||
|
if row.MetricValue == "explicit" {
|
||||||
|
result.ExplicitOptIn = row.MetricCount
|
||||||
|
}
|
||||||
|
case "ExplicitPhotoCount":
|
||||||
|
result.ExplicitPhoto = row.MetricCount
|
||||||
|
case "AgeCounts":
|
||||||
|
if _, ok := result.ByAgeRange[row.MetricValue]; !ok {
|
||||||
|
result.ByAgeRange[row.MetricValue] = 0
|
||||||
|
}
|
||||||
|
result.ByAgeRange[row.MetricValue] += row.MetricCount
|
||||||
|
case "GenderCount":
|
||||||
|
if _, ok := result.ByGender[row.MetricValue]; !ok {
|
||||||
|
result.ByGender[row.MetricValue] = 0
|
||||||
|
}
|
||||||
|
result.ByGender[row.MetricValue] += row.MetricCount
|
||||||
|
case "OrientationCount":
|
||||||
|
if _, ok := result.ByOrientation[row.MetricValue]; !ok {
|
||||||
|
result.ByOrientation[row.MetricValue] = 0
|
||||||
|
}
|
||||||
|
result.ByOrientation[row.MetricValue] += row.MetricCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// PhotoStatistics gets info about photo usage on the website.
|
||||||
|
//
|
||||||
|
// Counts of Explicit vs. Non-Explicit photos.
|
||||||
|
func PhotoStatistics() Photo {
|
||||||
|
var result Photo
|
||||||
|
type record struct {
|
||||||
|
Explicit bool
|
||||||
|
C int64
|
||||||
|
}
|
||||||
|
var records []record
|
||||||
|
|
||||||
|
res := models.DB.Raw(`
|
||||||
|
SELECT
|
||||||
|
photos.explicit,
|
||||||
|
count(photos.id) AS c
|
||||||
|
FROM
|
||||||
|
photos
|
||||||
|
WHERE
|
||||||
|
photos.visibility = 'public'
|
||||||
|
GROUP BY photos.explicit
|
||||||
|
ORDER BY c DESC
|
||||||
|
`).Scan(&records)
|
||||||
|
if res.Error != nil {
|
||||||
|
log.Error("PhotoStatistics: %s", res.Error)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, row := range records {
|
||||||
|
result.Total += row.C
|
||||||
|
if row.Explicit {
|
||||||
|
result.Explicit += row.C
|
||||||
|
} else {
|
||||||
|
result.NonExplicit += row.C
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
|
@ -19,30 +19,33 @@ func ExportModels(zw *zip.Writer, user *models.User) error {
|
||||||
// List of tables to export. Keep the ordering in sync with
|
// List of tables to export. Keep the ordering in sync with
|
||||||
// the AutoMigrate() calls in ../models.go
|
// the AutoMigrate() calls in ../models.go
|
||||||
var todo = []task{
|
var todo = []task{
|
||||||
{"User", ExportUserTable},
|
// Note: AdminGroup info is eager-loaded in User export
|
||||||
|
{"Block", ExportBlockTable},
|
||||||
|
{"CertificationPhoto", ExportCertificationPhotoTable},
|
||||||
|
{"ChangeLog", ExportChangeLogTable},
|
||||||
|
{"Comment", ExportCommentTable},
|
||||||
|
{"CommentPhoto", ExportCommentPhotoTable},
|
||||||
|
{"Feedback", ExportFeedbackTable},
|
||||||
|
{"ForumMembership", ExportForumMembershipTable},
|
||||||
|
{"Friend", ExportFriendTable},
|
||||||
|
{"Forum", ExportForumTable},
|
||||||
|
{"IPAddress", ExportIPAddressTable},
|
||||||
|
{"Like", ExportLikeTable},
|
||||||
|
{"Message", ExportMessageTable},
|
||||||
|
{"Notification", ExportNotificationTable},
|
||||||
{"ProfileField", ExportProfileFieldTable},
|
{"ProfileField", ExportProfileFieldTable},
|
||||||
{"Photo", ExportPhotoTable},
|
{"Photo", ExportPhotoTable},
|
||||||
{"PrivatePhoto", ExportPrivatePhotoTable},
|
|
||||||
{"CertificationPhoto", ExportCertificationPhotoTable},
|
|
||||||
{"Message", ExportMessageTable},
|
|
||||||
{"Friend", ExportFriendTable},
|
|
||||||
{"Block", ExportBlockTable},
|
|
||||||
{"Feedback", ExportFeedbackTable},
|
|
||||||
{"Forum", ExportForumTable},
|
|
||||||
{"Thread", ExportThreadTable},
|
|
||||||
{"Comment", ExportCommentTable},
|
|
||||||
{"Like", ExportLikeTable},
|
|
||||||
{"Notification", ExportNotificationTable},
|
|
||||||
{"Subscription", ExportSubscriptionTable},
|
|
||||||
{"CommentPhoto", ExportCommentPhotoTable},
|
|
||||||
// Note: Poll table is eager-loaded in Thread export
|
// Note: Poll table is eager-loaded in Thread export
|
||||||
{"PollVote", ExportPollVoteTable},
|
{"PollVote", ExportPollVoteTable},
|
||||||
// Note: AdminGroup info is eager-loaded in User export
|
{"PrivatePhoto", ExportPrivatePhotoTable},
|
||||||
|
{"PushNotification", ExportPushNotificationTable},
|
||||||
|
{"Subscription", ExportSubscriptionTable},
|
||||||
|
{"Thread", ExportThreadTable},
|
||||||
|
{"TwoFactor", ExportTwoFactorTable},
|
||||||
|
{"UsageStatistic", ExportUsageStatisticTable},
|
||||||
|
{"User", ExportUserTable},
|
||||||
{"UserLocation", ExportUserLocationTable},
|
{"UserLocation", ExportUserLocationTable},
|
||||||
{"UserNote", ExportUserNoteTable},
|
{"UserNote", ExportUserNoteTable},
|
||||||
{"ChangeLog", ExportChangeLogTable},
|
|
||||||
{"TwoFactor", ExportTwoFactorTable},
|
|
||||||
{"IPAddress", ExportIPAddressTable},
|
|
||||||
}
|
}
|
||||||
for _, item := range todo {
|
for _, item := range todo {
|
||||||
log.Info("Exporting data model: %s", item.Step)
|
log.Info("Exporting data model: %s", item.Step)
|
||||||
|
@ -444,3 +447,48 @@ func ExportIPAddressTable(zw *zip.Writer, user *models.User) error {
|
||||||
|
|
||||||
return ZipJson(zw, "ip_addresses.json", items)
|
return ZipJson(zw, "ip_addresses.json", items)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ExportForumMembershipTable(zw *zip.Writer, user *models.User) error {
|
||||||
|
var (
|
||||||
|
items = []*models.ForumMembership{}
|
||||||
|
query = models.DB.Model(&models.ForumMembership{}).Where(
|
||||||
|
"user_id = ?",
|
||||||
|
user.ID,
|
||||||
|
).Find(&items)
|
||||||
|
)
|
||||||
|
if query.Error != nil {
|
||||||
|
return query.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
return ZipJson(zw, "forum_memberships.json", items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExportPushNotificationTable(zw *zip.Writer, user *models.User) error {
|
||||||
|
var (
|
||||||
|
items = []*models.PushNotification{}
|
||||||
|
query = models.DB.Model(&models.PushNotification{}).Where(
|
||||||
|
"user_id = ?",
|
||||||
|
user.ID,
|
||||||
|
).Find(&items)
|
||||||
|
)
|
||||||
|
if query.Error != nil {
|
||||||
|
return query.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
return ZipJson(zw, "push_notifications.json", items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExportUsageStatisticTable(zw *zip.Writer, user *models.User) error {
|
||||||
|
var (
|
||||||
|
items = []*models.UsageStatistic{}
|
||||||
|
query = models.DB.Model(&models.UsageStatistic{}).Where(
|
||||||
|
"user_id = ?",
|
||||||
|
user.ID,
|
||||||
|
).Find(&items)
|
||||||
|
)
|
||||||
|
if query.Error != nil {
|
||||||
|
return query.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
return ZipJson(zw, "usage_statistics.json", items)
|
||||||
|
}
|
||||||
|
|
|
@ -31,9 +31,10 @@ func AutoMigrate() {
|
||||||
&Poll{}, // vacuum script cleans up orphaned polls
|
&Poll{}, // vacuum script cleans up orphaned polls
|
||||||
&PrivatePhoto{}, // ✔
|
&PrivatePhoto{}, // ✔
|
||||||
&PushNotification{}, // ✔
|
&PushNotification{}, // ✔
|
||||||
|
&Subscription{}, // ✔
|
||||||
&Thread{}, // ✔
|
&Thread{}, // ✔
|
||||||
&TwoFactor{}, // ✔
|
&TwoFactor{}, // ✔
|
||||||
&Subscription{}, // ✔
|
&UsageStatistic{}, // ✔
|
||||||
&User{}, // ✔
|
&User{}, // ✔
|
||||||
&UserLocation{}, // ✔
|
&UserLocation{}, // ✔
|
||||||
&UserNote{}, // ✔
|
&UserNote{}, // ✔
|
||||||
|
|
105
pkg/models/usage_statistic.go
Normal file
105
pkg/models/usage_statistic.go
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
/*
|
||||||
|
UsageStatistic holds basic analytics points for things like daily/monthly active user counts.
|
||||||
|
|
||||||
|
Generally, there will be one UserStatistic row for each combination of a UserID and Type for
|
||||||
|
each calendar day of the year. Type names may be like "dau" to log daily logins (Daily Active User),
|
||||||
|
or "chat" to log daily chat room users.
|
||||||
|
|
||||||
|
If a user logs in multiple times in the same day, their existing UsageStatistic for that day
|
||||||
|
is reused and the Counter is incremented. So if a user joins chat 3 times on the same day, there
|
||||||
|
will be a single row for that date for that user, but with a Counter of 3 in that case.
|
||||||
|
|
||||||
|
This makes it easier to query for aggregate reports on daily/monthly active users since each
|
||||||
|
row/event type combo only appears once per user per day.
|
||||||
|
*/
|
||||||
|
type UsageStatistic struct {
|
||||||
|
ID uint64 `gorm:"primaryKey"`
|
||||||
|
UserID uint64 `gorm:"uniqueIndex:idx_usage_statistics"`
|
||||||
|
Type string `gorm:"uniqueIndex:idx_usage_statistics"`
|
||||||
|
Date string `gorm:"uniqueIndex:idx_usage_statistics"` // unique days, yyyy-mm-dd format.
|
||||||
|
Counter uint64
|
||||||
|
CreatedAt time.Time `gorm:"index"` // full timestamps
|
||||||
|
UpdatedAt time.Time `gorm:"index"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options for UsageStatistic Type values.
|
||||||
|
const (
|
||||||
|
UsageStatisticDailyVisit = "dau" // daily active user counter
|
||||||
|
UsageStatisticChatEntry = "chat" // daily chat room users
|
||||||
|
UsageStatisticForumUser = "forum" // daily forum users (when they open a thread)
|
||||||
|
UsageStatisticGalleryUser = "gallery" // daily Site Gallery user (when viewing the site gallery)
|
||||||
|
)
|
||||||
|
|
||||||
|
// LogDailyActiveUser will ping a UserStatistic for the current user to mark them present for the day.
|
||||||
|
func LogDailyActiveUser(user *User) error {
|
||||||
|
var (
|
||||||
|
date = time.Now().Format(time.DateOnly)
|
||||||
|
_, err = IncrementUsageStatistic(user, UsageStatisticDailyVisit, date)
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogDailyChatUser will ping a UserStatistic for the current user to mark them as having used the chat room today.
|
||||||
|
func LogDailyChatUser(user *User) error {
|
||||||
|
var (
|
||||||
|
date = time.Now().Format(time.DateOnly)
|
||||||
|
_, err = IncrementUsageStatistic(user, UsageStatisticChatEntry, date)
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogDailyForumUser will ping a UserStatistic for the current user to mark them as having used the forums today.
|
||||||
|
func LogDailyForumUser(user *User) error {
|
||||||
|
var (
|
||||||
|
date = time.Now().Format(time.DateOnly)
|
||||||
|
_, err = IncrementUsageStatistic(user, UsageStatisticForumUser, date)
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogDailyGalleryUser will ping a UserStatistic for the current user to mark them as having used the site gallery today.
|
||||||
|
func LogDailyGalleryUser(user *User) error {
|
||||||
|
var (
|
||||||
|
date = time.Now().Format(time.DateOnly)
|
||||||
|
_, err = IncrementUsageStatistic(user, UsageStatisticGalleryUser, date)
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUsageStatistic looks up a user statistic.
|
||||||
|
func GetUsageStatistic(user *User, statType, date string) (*UsageStatistic, error) {
|
||||||
|
var (
|
||||||
|
result = &UsageStatistic{}
|
||||||
|
res = DB.Model(&UsageStatistic{}).Where(
|
||||||
|
"user_id = ? AND type = ? AND date = ?",
|
||||||
|
user.ID, statType, date,
|
||||||
|
).First(&result)
|
||||||
|
)
|
||||||
|
return result, res.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrementUsageStatistic finds or creates a UserStatistic type and increments the counter.
|
||||||
|
func IncrementUsageStatistic(user *User, statType, date string) (*UsageStatistic, error) {
|
||||||
|
user.muStatistic.Lock()
|
||||||
|
defer user.muStatistic.Unlock()
|
||||||
|
|
||||||
|
// Is there an existing row?
|
||||||
|
stat, err := GetUsageStatistic(user, statType, date)
|
||||||
|
if err != nil {
|
||||||
|
stat = &UsageStatistic{
|
||||||
|
UserID: user.ID,
|
||||||
|
Type: statType,
|
||||||
|
Counter: 0,
|
||||||
|
Date: date,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update and save it.
|
||||||
|
stat.Counter++
|
||||||
|
err = DB.Save(stat).Error
|
||||||
|
return stat, err
|
||||||
|
}
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/config"
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
|
@ -45,6 +46,9 @@ type User struct {
|
||||||
cachePhotoTypes map[PhotoVisibility]struct{}
|
cachePhotoTypes map[PhotoVisibility]struct{}
|
||||||
cacheBlockedUserIDs []uint64
|
cacheBlockedUserIDs []uint64
|
||||||
cachePhotoIDs []uint64
|
cachePhotoIDs []uint64
|
||||||
|
|
||||||
|
// Feature mutexes.
|
||||||
|
muStatistic sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserVisibility string
|
type UserVisibility string
|
||||||
|
@ -226,6 +230,17 @@ func IsValidUsername(username string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PingLastLoginAt refreshes the user's "last logged in" time.
|
||||||
|
func (u *User) PingLastLoginAt() error {
|
||||||
|
// Also ping their daily active user statistic.
|
||||||
|
if err := LogDailyActiveUser(u); err != nil {
|
||||||
|
log.Error("PingLastLoginAt(%s): couldn't log daily active user statistic: %s", u.Username, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
u.LastLoginAt = time.Now()
|
||||||
|
return u.Save()
|
||||||
|
}
|
||||||
|
|
||||||
// IsBanned returns if the user account is banned.
|
// IsBanned returns if the user account is banned.
|
||||||
func (u *User) IsBanned() bool {
|
func (u *User) IsBanned() bool {
|
||||||
return u.Status == UserStatusBanned
|
return u.Status == UserStatusBanned
|
||||||
|
|
|
@ -34,6 +34,7 @@ func New() http.Handler {
|
||||||
mux.HandleFunc("GET /sw.js", index.ServiceWorker())
|
mux.HandleFunc("GET /sw.js", index.ServiceWorker())
|
||||||
mux.HandleFunc("GET /about", index.StaticTemplate("about.html")())
|
mux.HandleFunc("GET /about", index.StaticTemplate("about.html")())
|
||||||
mux.HandleFunc("GET /features", index.StaticTemplate("features.html")())
|
mux.HandleFunc("GET /features", index.StaticTemplate("features.html")())
|
||||||
|
mux.HandleFunc("GET /insights", index.Demographics())
|
||||||
mux.HandleFunc("GET /faq", index.StaticTemplate("faq.html")())
|
mux.HandleFunc("GET /faq", index.StaticTemplate("faq.html")())
|
||||||
mux.HandleFunc("GET /tos", index.StaticTemplate("tos.html")())
|
mux.HandleFunc("GET /tos", index.StaticTemplate("tos.html")())
|
||||||
mux.HandleFunc("GET /privacy", index.StaticTemplate("privacy.html")())
|
mux.HandleFunc("GET /privacy", index.StaticTemplate("privacy.html")())
|
||||||
|
|
|
@ -172,8 +172,7 @@ func LoginUser(w http.ResponseWriter, r *http.Request, u *models.User) error {
|
||||||
sess.Save(w)
|
sess.Save(w)
|
||||||
|
|
||||||
// Ping the user's last login time.
|
// Ping the user's last login time.
|
||||||
u.LastLoginAt = time.Now()
|
return u.PingLastLoginAt()
|
||||||
return u.Save()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImpersonateUser assumes the role of the user impersonated by an admin uid.
|
// ImpersonateUser assumes the role of the user impersonated by an admin uid.
|
||||||
|
|
233
web/templates/demographics.html
Normal file
233
web/templates/demographics.html
Normal file
|
@ -0,0 +1,233 @@
|
||||||
|
{{define "title"}}A peek inside the {{.Title}} website{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="block">
|
||||||
|
<section class="hero is-light is-bold">
|
||||||
|
<div class="hero-body">
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="title">A peek inside the {{PrettyTitle}} website</h1>
|
||||||
|
<h2 class="subtitle">Some statistics & demographics of our members & content</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="block p-4">
|
||||||
|
<div class="content">
|
||||||
|
<p>
|
||||||
|
This page provides some insights into the distribution of content and member demographics
|
||||||
|
in the {{PrettyTitle}} community.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
If you are a prospective new member and are curious about whether this isn't actually "just another porn site,"
|
||||||
|
hopefully this page will help answer some of those questions for you.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
We have found that {{PrettyTitle}} actually fills a much needed niche <em>in between</em> the two
|
||||||
|
opposite poles of the "strict naturist website" and the "hyper sexual porn sites" that exist elsewhere
|
||||||
|
online. Many of our members have come here from other major nudist websites, and we have so far maintained a
|
||||||
|
nice balance in content: <strong>most</strong> of what people share here are "normal nudes" that
|
||||||
|
don't contain any sexual undertones at all!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
On our <a href="/features#webcam-chat-room">webcam chat room</a>, though we permit people to be sexual
|
||||||
|
on camera when they want to be, we typically maintain a balance where <em>most</em> of the webcams are
|
||||||
|
"blue" (or non-sexual in nature). Members are asked to mark their webcams as 'explicit' (red) when they are
|
||||||
|
being horny, so you may have informed consent as to whether you want to open their red cam and watch.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
The insights on this page should hopefully back up this story with some hard numbers so you may get a preview
|
||||||
|
of what you may expect to find on this website should you decide to join us here.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{{PrettyTitle}} is open to <strong>all</strong> nudists & exhibitionists and we'd love to have you join our community!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong><em>Last Updated:</em></strong> these website statistics were last collected
|
||||||
|
on {{.Demographic.LastUpdated.Format "Jan _2, 2006 @ 15:04:05 MST"}}
|
||||||
|
|
||||||
|
<!-- Admin: force refresh -->
|
||||||
|
{{if .CurrentUser.IsAdmin}}
|
||||||
|
<a href="{{.Request.URL.Path}}?refresh=true" class="has-text-danger ml-2">
|
||||||
|
<i class="fa fa-peace"></i> Refresh now
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Photo Gallery Statistics
|
||||||
|
-->
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<h2>
|
||||||
|
<i class="fa fa-image mr-2"></i>
|
||||||
|
Photo Gallery
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Our members have shared <strong>{{FormatNumberCommas .Demographic.Photo.Total}}</strong> photos on
|
||||||
|
"public" for the whole {{PrettyTitle}} community to see. The majority of these photos tend to be "normal nudes"
|
||||||
|
and are non-sexual in nature (for example, not featuring so much as an erection or sexually enticing pose).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column is-2">
|
||||||
|
<strong>Non-Explicit</strong>
|
||||||
|
<small class="has-text-grey">({{FormatNumberCommas .Demographic.Photo.NonExplicit}})</small>
|
||||||
|
</div>
|
||||||
|
<div class="column pt-4">
|
||||||
|
<progress class="progress is-link has-background-dark"
|
||||||
|
value="{{.Demographic.Photo.PercentNonExplicit}}"
|
||||||
|
max="100"
|
||||||
|
title="{{.Demographic.Photo.PercentNonExplicit}}%">
|
||||||
|
{{.Demographic.Photo.PercentNonExplicit}}%
|
||||||
|
</progress>
|
||||||
|
</div>
|
||||||
|
<div class="column is-narrow">
|
||||||
|
{{.Demographic.Photo.PercentNonExplicit}}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column is-2">
|
||||||
|
<strong>Explicit</strong>
|
||||||
|
<small class="has-text-grey">({{FormatNumberCommas .Demographic.Photo.Explicit}})</small>
|
||||||
|
</div>
|
||||||
|
<div class="column pt-4">
|
||||||
|
<progress class="progress is-danger has-background-dark"
|
||||||
|
value="{{.Demographic.Photo.PercentExplicit}}"
|
||||||
|
max="100"
|
||||||
|
title="{{.Demographic.Photo.PercentExplicit}}%">
|
||||||
|
{{.Demographic.Photo.PercentExplicit}}%
|
||||||
|
</progress>
|
||||||
|
</div>
|
||||||
|
<div class="column is-narrow">
|
||||||
|
{{.Demographic.Photo.PercentExplicit}}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<p>
|
||||||
|
Remember: this website is "nudist friendly" by default, and you must <strong>opt-in</strong> if you want to
|
||||||
|
see the 'explicit' content on this website.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
So far: <strong>{{FormatNumberCommas .Demographic.People.ExplicitOptIn}}</strong> of our members
|
||||||
|
({{.Demographic.People.PercentExplicit}}%) opt-in to see explicit content, and
|
||||||
|
<strong>{{FormatNumberCommas .Demographic.People.ExplicitPhoto}}</strong>
|
||||||
|
({{.Demographic.People.PercentExplicitPhoto}}%) of them have actually shared at least one 'explicit'
|
||||||
|
photo on their public gallery.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
People statistics
|
||||||
|
-->
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<h2>
|
||||||
|
<i class="fa fa-people-group mr-2"></i>
|
||||||
|
People
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
We currently have <strong>{{FormatNumberCommas .Demographic.People.Total}}</strong> members who have
|
||||||
|
verified their <a href="/faq#certification-faqs">certification photo</a> with the site admin.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Below are some high-level demographics of who you may expect to find on this website. As a note on
|
||||||
|
genders: the majority of our members tend to be men, but we do have a handful of women here too.
|
||||||
|
{{PrettyTitle}} grows <em>exclusively</em> by word of mouth: if you know any women who might like
|
||||||
|
the vibe of this place, please do recommend that they check it out!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column">
|
||||||
|
<h4 class="is-size-5 mb-4">By Age Range</h4>
|
||||||
|
|
||||||
|
{{range .Demographic.People.IterAgeRanges}}
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column is-3">
|
||||||
|
<strong>{{or .Label "(No answer)"}}</strong>
|
||||||
|
<small class="has-text-grey">({{FormatNumberCommas .Count}})</small>
|
||||||
|
</div>
|
||||||
|
<div class="column pt-4">
|
||||||
|
<progress class="progress is-success has-background-dark"
|
||||||
|
value="{{.Percent}}"
|
||||||
|
max="100"
|
||||||
|
title="{{.Percent}}%">
|
||||||
|
{{.Percent}}%
|
||||||
|
</progress>
|
||||||
|
</div>
|
||||||
|
<div class="column is-narrow">
|
||||||
|
{{.Percent}}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column">
|
||||||
|
<h4 class="is-size-5 mb-4">By Gender</h4>
|
||||||
|
|
||||||
|
{{range .Demographic.People.IterGenders}}
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column is-3">
|
||||||
|
<strong>{{or .Label "No answer"}}</strong>
|
||||||
|
<small class="has-text-grey">({{FormatNumberCommas .Count}})</small>
|
||||||
|
</div>
|
||||||
|
<div class="column pt-4">
|
||||||
|
<progress class="progress is-link has-background-dark"
|
||||||
|
value="{{.Percent}}"
|
||||||
|
max="100"
|
||||||
|
title="{{.Percent}}%">
|
||||||
|
{{.Percent}}%
|
||||||
|
</progress>
|
||||||
|
</div>
|
||||||
|
<div class="column is-narrow">
|
||||||
|
{{.Percent}}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column is-half">
|
||||||
|
<h4 class="is-size-5 mb-4">By Orientation</h4>
|
||||||
|
|
||||||
|
{{range .Demographic.People.IterOrientations}}
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column is-3">
|
||||||
|
<strong>{{or .Label "(No answer)"}}</strong>
|
||||||
|
<small class="has-text-grey">({{FormatNumberCommas .Count}})</small>
|
||||||
|
</div>
|
||||||
|
<div class="column pt-4">
|
||||||
|
<progress class="progress is-danger has-background-dark"
|
||||||
|
value="{{.Percent}}"
|
||||||
|
max="100"
|
||||||
|
title="{{.Percent}}%">
|
||||||
|
{{.Percent}}%
|
||||||
|
</progress>
|
||||||
|
</div>
|
||||||
|
<div class="column is-narrow">
|
||||||
|
{{.Percent}}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{{end}}
|
|
@ -41,29 +41,34 @@
|
||||||
content is strictly opt-in and the default is to hide any explicit photos or forums from your view.
|
content is strictly opt-in and the default is to hide any explicit photos or forums from your view.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<!-- Show a peek at website demographics -->
|
||||||
|
{{if .Demographic.Computed}}
|
||||||
<h4><em>A peek inside the site:</em></h4>
|
<h4><em>A peek inside the site:</em></h4>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
{{PrettyTitle}} has been found to fill a much needed niche in between the "strict naturist websites"
|
{{PrettyTitle}} has been found to fill a much needed niche in between the "strict naturist websites"
|
||||||
and the "hyper sexual porn sites" out there. As of <strong>July 31, 2024</strong> here is a brief peek inside the
|
and the "hyper sexual porn sites" out there. As of <strong>{{.Demographic.LastUpdated.Format "January _2, 2006"}}</strong> here is a brief
|
||||||
website to see what the balance of content is like in our community.
|
<a href="/insights">peek inside the website</a> to see what the balance of content is like in our community.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
Photo Gallery: only 24% of our photos are 'explicit' or sexual in nature (6,750 out of 27,331).
|
Photo Gallery: only {{.Demographic.Photo.PercentExplicit}}% of our photos are 'explicit' or sexual in nature ({{FormatNumberCommas .Demographic.Photo.Explicit}} out of {{FormatNumberCommas .Demographic.Photo.Total}}).
|
||||||
It is strictly opt-in if you want to see that stuff - it's hidden by default!
|
It is strictly opt-in if you want to see that stuff - it's hidden by default!
|
||||||
</li>
|
</li>
|
||||||
<li>Nudists vs. Exhibitionists: 3,209 (71%, out of 4,462) of members have opted-in to see explicit content on the site.
|
<li>Nudists vs. Exhibitionists: {{FormatNumberCommas .Demographic.People.ExplicitOptIn}} ({{.Demographic.People.PercentExplicit}}%, out of {{FormatNumberCommas .Demographic.People.Total}}) of members have opted-in to see explicit content on the site.
|
||||||
Only 45% of members (2,022) have shared at least one 'explicit' photo on their gallery.</li>
|
Only {{.Demographic.People.PercentExplicitPhoto}}% of members ({{FormatNumberCommas .Demographic.People.ExplicitPhoto}}) have shared at least one 'explicit' photo on their gallery.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
|
<strong>
|
||||||
|
<i class="fa fa-circle-arrow-right mr-1"></i> See more:
|
||||||
|
</strong>
|
||||||
<small>
|
<small>
|
||||||
(Coming soon: a 'live statistics' page which will give up-to-date information and pretty graphs & charts;
|
<a href="/insights">Click here to see detailed insights</a> about the people and content in our community -- updated regularly!
|
||||||
in the mean time these were manually gathered).
|
|
||||||
</small>
|
</small>
|
||||||
</p>
|
</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue
Block a user